this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

alt text and video player

scanash00 54c7a998 3d9abbe0

+315 -49
+2 -1
README.md
··· 54 54 Here's a guide that might be helpful: [Setting a custom homepage on a PDS](https://willdot.leaflet.pub/3m25uvnuwnk2t) 55 55 56 56 # License 57 - Licensed under the MIT License 57 + 58 + Licensed under the MIT License
+3
bun.lock
··· 6 6 "dependencies": { 7 7 "@atproto/api": "^0.18.3", 8 8 "@heroicons/react": "^2.2.0", 9 + "hls.js": "^1.6.15", 9 10 "next": "16.0.5", 10 11 "react": "19.2.0", 11 12 "react-dom": "19.2.0", ··· 530 531 "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], 531 532 532 533 "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], 534 + 535 + "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], 533 536 534 537 "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 535 538
+1
package.json
··· 13 13 "dependencies": { 14 14 "@atproto/api": "^0.18.3", 15 15 "@heroicons/react": "^2.2.0", 16 + "hls.js": "^1.6.15", 16 17 "next": "16.0.5", 17 18 "react": "19.2.0", 18 19 "react-dom": "19.2.0"
+55
src/components/AltBadge.tsx
··· 1 + 'use client'; 2 + 3 + import {useState} from 'react'; 4 + 5 + interface AltBadgeProps { 6 + text: string; 7 + } 8 + 9 + export function AltBadge({text}: AltBadgeProps) { 10 + const [isOpen, setIsOpen] = useState(false); 11 + 12 + return ( 13 + <div className="absolute bottom-3 left-3 z-20"> 14 + <button 15 + onClick={e => { 16 + e.preventDefault(); 17 + e.stopPropagation(); 18 + setIsOpen(!isOpen); 19 + }} 20 + className={`text-xs font-bold px-3 py-1 rounded-full border-2 border-black transition-all hover:scale-105 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] flex items-center gap-1 ${ 21 + isOpen ? 'bg-[#f9ed69] text-black' : 'bg-white text-black hover:bg-zinc-50' 22 + }`} 23 + aria-label="Show image description" 24 + aria-expanded={isOpen} 25 + > 26 + ALT 27 + </button> 28 + {isOpen && ( 29 + <> 30 + <div 31 + className="fixed inset-0 z-20 cursor-default" 32 + onClick={e => { 33 + e.preventDefault(); 34 + e.stopPropagation(); 35 + setIsOpen(false); 36 + }} 37 + /> 38 + <div 39 + className="absolute bottom-full left-0 mb-3 w-72 max-w-[calc(100vw-4rem)] bg-[#f9ed69] border-3 border-black p-4 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] text-black z-30 animate-in fade-in slide-in-from-bottom-2 duration-150" 40 + onClick={e => { 41 + e.preventDefault(); 42 + e.stopPropagation(); 43 + }} 44 + > 45 + <div className="font-black mb-2 text-xs uppercase tracking-wider border-b-2 border-black/20 pb-1"> 46 + Description 47 + </div> 48 + <p className="leading-relaxed font-medium text-sm whitespace-pre-wrap">{text}</p> 49 + <div className="absolute -bottom-[9px] left-4 w-4 h-4 bg-[#f9ed69] border-b-3 border-r-3 border-black transform rotate-45"></div> 50 + </div> 51 + </> 52 + )} 53 + </div> 54 + ); 55 + }
+121 -48
src/components/PostFeed.tsx
··· 5 5 import {Post} from '@/lib/atproto'; 6 6 import {ChatBubbleOvalLeftIcon, ArrowPathIcon, HeartIcon} from '@heroicons/react/24/solid'; 7 7 8 + import {VideoPlayer} from './VideoPlayer'; 9 + import {AltBadge} from './AltBadge'; 10 + 8 11 const BSKY_APP_URL_RAW = process.env.NEXT_PUBLIC_BSKY_APP_URL || 'https://bsky.app'; 9 12 const BSKY_APP_URL = BSKY_APP_URL_RAW.startsWith('http') 10 13 ? BSKY_APP_URL_RAW ··· 17 20 interface EmbedImage { 18 21 fullsize?: string; 19 22 thumb?: string; 23 + alt?: string; 20 24 image?: {ref: {toString: () => string}}; 21 25 } 22 26 ··· 27 31 thumb?: string | {ref: {toString: () => string}}; 28 32 } 29 33 34 + interface EmbedVideo { 35 + playlist: string; 36 + thumbnail?: string; 37 + alt?: string; 38 + aspectRatio?: {width: number; height: number}; 39 + } 40 + 30 41 interface EmbedView { 31 42 $type: string; 32 43 images?: EmbedImage[]; 33 44 media?: { 34 45 images?: EmbedImage[]; 46 + video?: EmbedVideo; 35 47 $type?: string; 48 + playlist?: string; 49 + thumbnail?: string; 50 + alt?: string; 51 + aspectRatio?: {width: number; height: number}; 36 52 }; 37 53 external?: EmbedExternal; 54 + playlist?: string; 55 + thumbnail?: string; 56 + alt?: string; 57 + aspectRatio?: {width: number; height: number}; 38 58 } 39 59 40 60 function extractRkey(uri: string): string { ··· 121 141 const avatarInitial = isInvalidHandle ? '⚠️' : post.author.handle[0]?.toUpperCase() || '?'; 122 142 123 143 const embed = post.record.embed as EmbedView | undefined; 124 - let images: string[] = []; 144 + let images: {url: string; alt?: string}[] = []; 125 145 let externalLink: { 126 146 url: string; 127 147 title?: string; ··· 129 149 thumb?: string; 130 150 } | null = null; 131 151 152 + let video: { 153 + playlist: string; 154 + thumbnail?: string; 155 + alt?: string; 156 + aspectRatio?: {width: number; height: number}; 157 + } | null = null; 158 + 132 159 if ( 133 160 embed && 134 161 embed.$type !== 'app.bsky.embed.record' && 135 162 embed.$type !== 'app.bsky.embed.record#view' 136 163 ) { 137 164 if ( 165 + embed.$type === 'app.bsky.embed.video#view' || 166 + embed.$type === 'app.bsky.embed.video' 167 + ) { 168 + if (embed.playlist) { 169 + video = { 170 + playlist: embed.playlist, 171 + thumbnail: embed.thumbnail, 172 + alt: embed.alt, 173 + aspectRatio: embed.aspectRatio, 174 + }; 175 + } 176 + } else if ( 138 177 embed.$type === 'app.bsky.embed.images#view' || 139 178 embed.$type === 'app.bsky.embed.images' 140 179 ) { 141 180 const imageList = embed.images || []; 142 181 images = imageList 143 - .map(img => { 144 - if (img.fullsize) return img.fullsize; 145 - if (img.thumb) return img.thumb; 146 - if (img.image?.ref) { 182 + .map((img): {url: string; alt?: string} | null => { 183 + let url = ''; 184 + if (img.fullsize) url = img.fullsize; 185 + else if (img.thumb) url = img.thumb; 186 + else if (img.image?.ref) { 147 187 const cid = img.image.ref.toString(); 148 - return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 188 + url = `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 149 189 } 150 - return ''; 190 + if (!url) return null; 191 + return {url, alt: img.alt}; 151 192 }) 152 - .filter(Boolean); 193 + .filter((img): img is {url: string; alt?: string} => img !== null); 153 194 } else if ( 154 195 embed.$type === 'app.bsky.embed.recordWithMedia#view' || 155 196 embed.$type === 'app.bsky.embed.recordWithMedia' 156 197 ) { 157 198 const media = embed.media; 158 - if ( 159 - media && 160 - (media.$type === 'app.bsky.embed.images#view' || 161 - media.$type === 'app.bsky.embed.images') 162 - ) { 163 - const imageList = media.images || []; 164 - images = imageList 165 - .map(img => { 166 - if (img.fullsize) return img.fullsize; 167 - if (img.thumb) return img.thumb; 168 - if (img.image?.ref) { 169 - const cid = img.image.ref.toString(); 170 - return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 171 - } 172 - return ''; 173 - }) 174 - .filter(Boolean); 199 + if (media) { 200 + if ( 201 + media.$type === 'app.bsky.embed.images#view' || 202 + media.$type === 'app.bsky.embed.images' 203 + ) { 204 + const imageList = media.images || []; 205 + images = imageList 206 + .map((img): {url: string; alt?: string} | null => { 207 + let url = ''; 208 + if (img.fullsize) url = img.fullsize; 209 + else if (img.thumb) url = img.thumb; 210 + else if (img.image?.ref) { 211 + const cid = img.image.ref.toString(); 212 + url = `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 213 + } 214 + if (!url) return null; 215 + return {url, alt: img.alt}; 216 + }) 217 + .filter((img): img is {url: string; alt?: string} => img !== null); 218 + } else if ( 219 + (media.$type === 'app.bsky.embed.video#view' || 220 + media.$type === 'app.bsky.embed.video') && 221 + media.playlist 222 + ) { 223 + const vid = media as unknown as EmbedVideo; 224 + video = { 225 + playlist: vid.playlist, 226 + thumbnail: vid.thumbnail, 227 + alt: vid.alt, 228 + aspectRatio: vid.aspectRatio, 229 + }; 230 + } 175 231 } 176 232 } else if ( 177 233 embed.$type === 'app.bsky.embed.external#view' || ··· 197 253 } 198 254 } 199 255 200 - let quotedImages: string[] = []; 256 + let quotedImages: {url: string; alt?: string}[] = []; 201 257 let quotedExternalLink: { 202 258 url: string; 203 259 title?: string; ··· 213 269 ) { 214 270 const imageList = quotedEmbed.images || []; 215 271 quotedImages = imageList 216 - .map(img => { 217 - if (img.fullsize) return img.fullsize; 218 - if (img.thumb) return img.thumb; 219 - if (img.image?.ref && post.quotedPost?.author?.did) { 272 + .map((img): {url: string; alt?: string} | null => { 273 + let url = ''; 274 + if (img.fullsize) url = img.fullsize; 275 + else if (img.thumb) url = img.thumb; 276 + else if (img.image?.ref && post.quotedPost?.author?.did) { 220 277 const cid = img.image.ref.toString(); 221 - return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.quotedPost.author.did}/${cid}@jpeg`; 278 + url = `https://cdn.bsky.app/img/feed_fullsize/plain/${post.quotedPost.author.did}/${cid}@jpeg`; 222 279 } 223 - return ''; 280 + if (!url) return null; 281 + return {url, alt: img.alt}; 224 282 }) 225 - .filter(Boolean); 283 + .filter((img): img is {url: string; alt?: string} => img !== null); 226 284 } else if ( 227 285 quotedEmbed.$type === 'app.bsky.embed.recordWithMedia#view' || 228 286 quotedEmbed.$type === 'app.bsky.embed.recordWithMedia' ··· 235 293 ) { 236 294 const imageList = media.images || []; 237 295 quotedImages = imageList 238 - .map(img => { 239 - if (img.fullsize) return img.fullsize; 240 - if (img.thumb) return img.thumb; 241 - if (img.image?.ref && post.quotedPost?.author?.did) { 296 + .map((img): {url: string; alt?: string} | null => { 297 + let url = ''; 298 + if (img.fullsize) url = img.fullsize; 299 + else if (img.thumb) url = img.thumb; 300 + else if (img.image?.ref && post.quotedPost?.author?.did) { 242 301 const cid = img.image.ref.toString(); 243 - return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.quotedPost.author.did}/${cid}@jpeg`; 302 + url = `https://cdn.bsky.app/img/feed_fullsize/plain/${post.quotedPost.author.did}/${cid}@jpeg`; 244 303 } 245 - return ''; 304 + if (!url) return null; 305 + return {url, alt: img.alt}; 246 306 }) 247 - .filter(Boolean); 307 + .filter((img): img is {url: string; alt?: string} => img !== null); 248 308 } 249 309 } else if ( 250 310 quotedEmbed.$type === 'app.bsky.embed.external#view' || ··· 377 437 </p> 378 438 </a> 379 439 440 + {video && ( 441 + <div className="mt-4"> 442 + <VideoPlayer 443 + playlist={video.playlist} 444 + thumbnail={video.thumbnail} 445 + alt={video.alt} 446 + aspectRatio={video.aspectRatio} 447 + /> 448 + </div> 449 + )} 450 + 380 451 {images.length > 0 && ( 381 452 <div 382 453 className={`mt-4 grid gap-2 ${images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`} 383 454 > 384 - {images.map((imgUrl, idx) => ( 455 + {images.map((img, idx) => ( 385 456 <div 386 457 key={idx} 387 458 className="w-full rounded-lg border-2 border-black overflow-hidden bg-zinc-100 flex items-center justify-center relative" ··· 391 462 }} 392 463 > 393 464 <Image 394 - src={imgUrl} 395 - alt={`Image ${idx + 1}`} 465 + src={img.url} 466 + alt={img.alt || `Image ${idx + 1}`} 396 467 fill 397 468 className="object-contain" 398 469 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" 399 - unoptimized={!imgUrl.includes('cdn.bsky.app')} 470 + unoptimized={!img.url.includes('cdn.bsky.app')} 400 471 /> 472 + {img.alt && <AltBadge text={img.alt} />} 401 473 </div> 402 474 ))} 403 475 </div> ··· 526 598 <div 527 599 className={`mt-2 grid gap-2 ${quotedImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`} 528 600 > 529 - {quotedImages.map((imgUrl, idx) => ( 601 + {quotedImages.map((img, idx) => ( 530 602 <div 531 603 key={idx} 532 604 className="w-full rounded border-2 border-zinc-300 overflow-hidden bg-zinc-100 flex items-center justify-center relative" ··· 536 608 }} 537 609 > 538 610 <Image 539 - src={imgUrl} 540 - alt={`Quoted image ${idx + 1}`} 611 + src={img.url} 612 + alt={img.alt || `Quoted image ${idx + 1}`} 541 613 fill 542 614 className="object-contain" 543 615 sizes="(max-width: 768px) 100vw, 50vw" 544 - unoptimized={!imgUrl.includes('cdn.bsky.app')} 616 + unoptimized={!img.url.includes('cdn.bsky.app')} 545 617 /> 618 + {img.alt && <AltBadge text={img.alt} />} 546 619 </div> 547 620 ))} 548 621 </div>
+133
src/components/VideoPlayer.tsx
··· 1 + 'use client'; 2 + 3 + import React, {useEffect, useRef, useState} from 'react'; 4 + import Hls from 'hls.js'; 5 + import {PlayIcon, SpeakerWaveIcon, SpeakerXMarkIcon} from '@heroicons/react/24/solid'; 6 + import {AltBadge} from './AltBadge'; 7 + 8 + interface VideoPlayerProps { 9 + playlist: string; 10 + thumbnail?: string; 11 + alt?: string; 12 + aspectRatio?: {width: number; height: number}; 13 + } 14 + 15 + export function VideoPlayer({playlist, thumbnail, alt, aspectRatio}: VideoPlayerProps) { 16 + const videoRef = useRef<HTMLVideoElement>(null); 17 + const [isPlaying, setIsPlaying] = useState(false); 18 + const [isMuted, setIsMuted] = useState(true); 19 + const [isHovered, setIsHovered] = useState(false); 20 + 21 + useEffect(() => { 22 + const video = videoRef.current; 23 + if (!video) return; 24 + 25 + let hls: Hls | null = null; 26 + 27 + if (Hls.isSupported()) { 28 + hls = new Hls({ 29 + capLevelToPlayerSize: true, 30 + }); 31 + hls.loadSource(playlist); 32 + hls.attachMedia(video); 33 + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { 34 + video.src = playlist; 35 + } 36 + 37 + return () => { 38 + if (hls) { 39 + hls.destroy(); 40 + } 41 + }; 42 + }, [playlist]); 43 + 44 + const togglePlay = (e: React.MouseEvent) => { 45 + e.stopPropagation(); 46 + const video = videoRef.current; 47 + if (!video) return; 48 + 49 + if (isPlaying) { 50 + video.pause(); 51 + } else { 52 + video.play().catch(console.error); 53 + } 54 + setIsPlaying(!isPlaying); 55 + }; 56 + 57 + const toggleMute = (e: React.MouseEvent) => { 58 + e.stopPropagation(); 59 + const video = videoRef.current; 60 + if (!video) return; 61 + 62 + video.muted = !isMuted; 63 + setIsMuted(!isMuted); 64 + }; 65 + 66 + const ratio = aspectRatio ? aspectRatio.height / aspectRatio.width : 9 / 16; 67 + const paddingBottom = `${ratio * 100}%`; 68 + 69 + return ( 70 + <div 71 + className="relative w-full border-2 border-black rounded-lg overflow-hidden bg-black group" 72 + style={{paddingBottom}} 73 + onMouseEnter={() => setIsHovered(true)} 74 + onMouseLeave={() => setIsHovered(false)} 75 + aria-label={alt || 'Video player'} 76 + role="application" 77 + > 78 + <video 79 + ref={videoRef} 80 + className="absolute top-0 left-0 w-full h-full object-contain" 81 + poster={thumbnail} 82 + muted={isMuted} 83 + playsInline 84 + loop 85 + onPlay={() => setIsPlaying(true)} 86 + onPause={() => setIsPlaying(false)} 87 + onClick={togglePlay} 88 + /> 89 + 90 + <div 91 + className={`absolute inset-0 transition-opacity duration-200 flex items-center justify-center pointer-events-none ${ 92 + isPlaying && !isHovered ? 'opacity-0' : 'opacity-100' 93 + }`} 94 + > 95 + {!isPlaying && ( 96 + <button 97 + onClick={togglePlay} 98 + className="pointer-events-auto comic-button bg-red-500 text-white p-4 rounded-full hover:bg-red-600 hover:scale-110 transition-transform shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] border-2 border-black" 99 + > 100 + <PlayIcon className="w-8 h-8 ml-1" /> 101 + </button> 102 + )} 103 + </div> 104 + 105 + {alt && ( 106 + <div 107 + className={`transition-opacity duration-200 ${ 108 + isPlaying && !isHovered ? 'opacity-0' : 'opacity-100' 109 + }`} 110 + > 111 + <AltBadge text={alt} /> 112 + </div> 113 + )} 114 + 115 + <div 116 + className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 flex justify-end transition-opacity duration-200 ${ 117 + isPlaying && !isHovered ? 'opacity-0' : 'opacity-100' 118 + }`} 119 + > 120 + <button 121 + onClick={toggleMute} 122 + className="pointer-events-auto bg-white/90 p-2 rounded-full border-2 border-black hover:bg-white hover:scale-105 transition-transform shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]" 123 + > 124 + {isMuted ? ( 125 + <SpeakerXMarkIcon className="w-5 h-5 text-black" /> 126 + ) : ( 127 + <SpeakerWaveIcon className="w-5 h-5 text-black" /> 128 + )} 129 + </button> 130 + </div> 131 + </div> 132 + ); 133 + }