An entry for the streamplace vod showcase
1
fork

Configure Feed

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

feat(web): add sprite preview to all video cards

- Extract shared VideoCard component with sprite preview
- Update SearchPage to use VideoCard with previews
- Update ProfilePage to use VideoCard with previews
- Use batch metadata loading on ProfilePage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+470 -286
+23 -118
apps/web/src/ui/ProfilePage.tsx
··· 3 3 */ 4 4 5 5 import { useState, useEffect, useMemo } from "react" 6 - import { listVideos, getVideoMetadata, formatDuration, getProfile, type Video, type VideoMetadata, type Profile } from "../api" 6 + import { listVideos, batchCheckMetadata, formatDuration, getProfile, type Video, type VideoMetadata, type Profile } from "../api" 7 7 import { navigate } from "../router" 8 + import { VideoCard, videoCardStyles } from "./VideoCard" 8 9 9 10 const styles = ` 10 11 @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&display=swap'); ··· 183 184 .profile-grid { grid-template-columns: 1fr; } 184 185 } 185 186 186 - .profile-card { 187 - cursor: pointer; 188 - } 189 - 190 - .profile-card-media { 191 - aspect-ratio: 16 / 10; 192 - border-radius: 8px; 193 - overflow: hidden; 194 - margin-bottom: 1rem; 195 - background: var(--bg-secondary); 196 - position: relative; 197 - } 198 - 199 - .profile-card-img { 200 - width: 100%; 201 - height: 100%; 202 - object-fit: cover; 203 - transition: transform 0.4s ease; 204 - } 205 - 206 - .profile-card:hover .profile-card-img { 207 - transform: scale(1.05); 208 - } 209 - 210 - .profile-card-fallback { 211 - width: 100%; 212 - height: 100%; 213 - display: flex; 214 - flex-direction: column; 215 - align-items: center; 216 - justify-content: center; 217 - background: var(--card-bg); 218 - color: var(--text); 219 - gap: 0.75rem; 220 - } 221 - 222 - .profile-card-fallback-icon { 223 - font-size: 2rem; 224 - opacity: 0.5; 225 - } 226 - 227 - .profile-card-fallback-text { 228 - font-family: 'Instrument Serif', Georgia, serif; 229 - font-size: 0.875rem; 230 - text-align: center; 231 - padding: 0 1rem; 232 - line-height: 1.3; 233 - opacity: 0.7; 234 - display: -webkit-box; 235 - -webkit-line-clamp: 2; 236 - -webkit-box-orient: vertical; 237 - overflow: hidden; 238 - } 239 - 240 - .profile-card-duration { 241 - position: absolute; 242 - bottom: 0.75rem; 243 - right: 0.75rem; 244 - padding: 0.25rem 0.5rem; 245 - background: rgba(0,0,0,0.8); 246 - color: #fff; 247 - font-size: 0.75rem; 248 - font-weight: 500; 249 - border-radius: 4px; 250 - } 251 - 252 - .profile-card-title { 253 - font-family: 'Instrument Serif', Georgia, serif; 254 - font-size: 1.375rem; 255 - font-weight: 400; 256 - line-height: 1.3; 257 - margin: 0 0 0.5rem; 258 - } 259 - 260 - .profile-card-meta { 261 - font-size: 0.8125rem; 262 - color: var(--text-muted); 263 - } 264 - 265 187 .profile-empty { 266 188 text-align: center; 267 189 padding: 4rem 2rem; ··· 310 232 width: 100%; 311 233 max-width: 500px; 312 234 } 235 + 236 + ${videoCardStyles} 313 237 ` 314 238 315 239 interface ProfilePageProps { ··· 326 250 setIsLoading(true) 327 251 setProfile(null) 328 252 setVideos([]) 253 + setMetadataMap(new Map()) 329 254 330 255 // Load profile 331 256 getProfile(did) ··· 333 258 .catch((err) => console.error("Failed to load profile:", err)) 334 259 335 260 // Load all videos and filter by creator 336 - listVideos(100).then((r) => { 261 + listVideos(100).then(async (r) => { 337 262 const creatorVideos = r.videos.filter((v) => v.creator === did) 338 263 setVideos(creatorVideos) 339 264 setIsLoading(false) 340 265 341 - // Load metadata for creator's videos 342 - creatorVideos.forEach((v) => { 343 - getVideoMetadata(v.uri).then((m) => { 344 - if (m) setMetadataMap((prev) => new Map(prev).set(v.uri, m)) 345 - }) 346 - }) 266 + // Batch load metadata for creator's videos 267 + if (creatorVideos.length > 0) { 268 + const uris = creatorVideos.map((v) => v.uri) 269 + const metadataResults = await batchCheckMetadata(uris) 270 + setMetadataMap(new Map( 271 + [...metadataResults.entries()].filter(([, m]) => m !== null) as [string, VideoMetadata][] 272 + )) 273 + } 347 274 }) 348 275 }, [did]) 349 276 ··· 428 355 </div> 429 356 ) : sortedVideos.length > 0 ? ( 430 357 <div className="profile-grid"> 431 - {sortedVideos.map((video) => { 432 - const meta = metadataMap.get(video.uri) 433 - const thumb = meta?.thumbnailUrl || null 434 - return ( 435 - <article 436 - key={video.uri} 437 - className="profile-card" 438 - onClick={() => navigate(`/?play=${encodeURIComponent(video.uri)}`)} 439 - > 440 - <div className="profile-card-media"> 441 - {thumb ? ( 442 - <img className="profile-card-img" src={thumb} alt="" /> 443 - ) : ( 444 - <div className="profile-card-fallback"> 445 - <span className="profile-card-fallback-icon">▶</span> 446 - <span className="profile-card-fallback-text">{video.title}</span> 447 - </div> 448 - )} 449 - <span className="profile-card-duration">{formatDuration(video.duration)}</span> 450 - </div> 451 - <h4 className="profile-card-title">{video.title}</h4> 452 - <div className="profile-card-meta"> 453 - {new Date(video.createdAt).toLocaleDateString("en-US", { 454 - month: "long", 455 - day: "numeric", 456 - year: "numeric" 457 - })} 458 - </div> 459 - </article> 460 - ) 461 - })} 358 + {sortedVideos.map((video) => ( 359 + <VideoCard 360 + key={video.uri} 361 + video={video} 362 + metadata={metadataMap.get(video.uri)} 363 + showCreator={false} 364 + onClick={() => navigate(`/?play=${encodeURIComponent(video.uri)}`)} 365 + /> 366 + ))} 462 367 </div> 463 368 ) : ( 464 369 <div className="profile-empty">
+13 -168
apps/web/src/ui/SearchPage.tsx
··· 3 3 */ 4 4 5 5 import { useState, useEffect, useMemo } from "react" 6 - import { listVideos, batchCheckMetadata, batchGetProfiles, formatDuration, type Video, type VideoMetadata, type Profile } from "../api" 6 + import { listVideos, batchCheckMetadata, batchGetProfiles, type Video, type VideoMetadata, type Profile } from "../api" 7 7 import { navigate, getSearchParams } from "../router" 8 + import { VideoCard, videoCardStyles } from "./VideoCard" 8 9 9 10 const styles = ` 10 11 @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&display=swap'); ··· 124 125 .search-grid { grid-template-columns: 1fr; } 125 126 } 126 127 127 - .search-card { 128 - cursor: pointer; 129 - } 130 - 131 - .search-card-media { 132 - aspect-ratio: 16 / 10; 133 - border-radius: 8px; 134 - overflow: hidden; 135 - margin-bottom: 1rem; 136 - background: var(--bg-secondary); 137 - position: relative; 138 - } 139 - 140 - .search-card-img { 141 - width: 100%; 142 - height: 100%; 143 - object-fit: cover; 144 - transition: transform 0.4s ease; 145 - } 146 - 147 - .search-card:hover .search-card-img { 148 - transform: scale(1.05); 149 - } 150 - 151 - .search-card-fallback { 152 - width: 100%; 153 - height: 100%; 154 - display: flex; 155 - flex-direction: column; 156 - align-items: center; 157 - justify-content: center; 158 - background: var(--card-bg); 159 - color: var(--text); 160 - gap: 0.75rem; 161 - } 162 - 163 - .search-card-fallback-icon { 164 - font-size: 2rem; 165 - opacity: 0.5; 166 - } 167 - 168 - .search-card-fallback-text { 169 - font-family: 'Instrument Serif', Georgia, serif; 170 - font-size: 0.875rem; 171 - text-align: center; 172 - padding: 0 1rem; 173 - line-height: 1.3; 174 - opacity: 0.7; 175 - display: -webkit-box; 176 - -webkit-line-clamp: 2; 177 - -webkit-box-orient: vertical; 178 - overflow: hidden; 179 - } 180 - 181 - .search-card-duration { 182 - position: absolute; 183 - bottom: 0.75rem; 184 - right: 0.75rem; 185 - padding: 0.25rem 0.5rem; 186 - background: rgba(0,0,0,0.8); 187 - color: #fff; 188 - font-size: 0.75rem; 189 - font-weight: 500; 190 - border-radius: 4px; 191 - } 192 - 193 - .search-card-title { 194 - font-family: 'Instrument Serif', Georgia, serif; 195 - font-size: 1.375rem; 196 - font-weight: 400; 197 - line-height: 1.3; 198 - margin: 0 0 0.5rem; 199 - } 200 - 201 - .search-card-meta { 202 - display: flex; 203 - align-items: center; 204 - gap: 0.75rem; 205 - font-size: 0.8125rem; 206 - color: var(--text-muted); 207 - } 208 - 209 - .search-card-creator { 210 - display: flex; 211 - align-items: center; 212 - gap: 0.5rem; 213 - cursor: pointer; 214 - transition: opacity 0.2s; 215 - } 216 - 217 - .search-card-creator:hover { 218 - opacity: 0.7; 219 - } 220 - 221 - .search-card-avatar { 222 - width: 24px; 223 - height: 24px; 224 - border-radius: 50%; 225 - object-fit: cover; 226 - } 227 - 228 - .search-card-avatar-fallback { 229 - width: 24px; 230 - height: 24px; 231 - border-radius: 50%; 232 - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 233 - display: flex; 234 - align-items: center; 235 - justify-content: center; 236 - font-size: 0.625rem; 237 - font-weight: 600; 238 - color: #fff; 239 - } 240 - 241 128 .search-empty { 242 129 text-align: center; 243 130 padding: 4rem 2rem; ··· 291 178 height: 1rem; 292 179 width: 40%; 293 180 } 181 + 182 + ${videoCardStyles} 294 183 ` 295 184 296 185 export function SearchPage() { ··· 340 229 } 341 230 } 342 231 343 - const handleCreatorClick = (e: React.MouseEvent, did: string) => { 344 - e.stopPropagation() 345 - navigate(`/profile/${did}`) 346 - } 347 - 348 232 return ( 349 233 <> 350 234 <style>{styles}</style> ··· 397 281 </div> 398 282 ) : results.length > 0 ? ( 399 283 <div className="search-grid"> 400 - {results.map((video) => { 401 - const meta = metadataMap.get(video.uri) 402 - const profile = video.creator ? profileMap.get(video.creator) : undefined 403 - const creatorName = profile?.displayName || profile?.handle || null 404 - return ( 405 - <article 406 - key={video.uri} 407 - className="search-card" 408 - onClick={() => navigate(`/?play=${encodeURIComponent(video.uri)}`)} 409 - > 410 - <div className="search-card-media"> 411 - {meta?.thumbnailUrl ? ( 412 - <img className="search-card-img" src={meta.thumbnailUrl} alt="" /> 413 - ) : ( 414 - <div className="search-card-fallback"> 415 - <span className="search-card-fallback-icon">▶</span> 416 - <span className="search-card-fallback-text">{video.title}</span> 417 - </div> 418 - )} 419 - <span className="search-card-duration">{formatDuration(video.duration)}</span> 420 - </div> 421 - <h3 className="search-card-title">{video.title}</h3> 422 - <div className="search-card-meta"> 423 - {profile && ( 424 - <div 425 - className="search-card-creator" 426 - onClick={(e) => handleCreatorClick(e, video.creator)} 427 - > 428 - {profile.avatar ? ( 429 - <img className="search-card-avatar" src={profile.avatar} alt="" /> 430 - ) : ( 431 - <div className="search-card-avatar-fallback"> 432 - {creatorName?.[0]?.toUpperCase() || '?'} 433 - </div> 434 - )} 435 - <span>{creatorName}</span> 436 - </div> 437 - )} 438 - <span> 439 - {new Date(video.createdAt).toLocaleDateString('en-US', { 440 - month: 'short', 441 - day: 'numeric' 442 - })} 443 - </span> 444 - </div> 445 - </article> 446 - ) 447 - })} 284 + {results.map((video) => ( 285 + <VideoCard 286 + key={video.uri} 287 + video={video} 288 + metadata={metadataMap.get(video.uri)} 289 + profile={video.creator ? profileMap.get(video.creator) : undefined} 290 + onClick={() => navigate(`/?play=${encodeURIComponent(video.uri)}`)} 291 + /> 292 + ))} 448 293 </div> 449 294 ) : ( 450 295 <div className="search-empty">
+434
apps/web/src/ui/VideoCard.tsx
··· 1 + /** 2 + * Shared Video Card component with sprite preview 3 + */ 4 + 5 + import { useState, useEffect, useRef, useCallback } from "react" 6 + import { formatDuration, type Video, type VideoMetadata, type Profile } from "../api" 7 + import { navigate } from "../router" 8 + 9 + // Parse VTT to extract sprite frame coordinates 10 + interface SpriteFrame { 11 + start: number 12 + end: number 13 + x: number 14 + y: number 15 + w: number 16 + h: number 17 + } 18 + 19 + function parseVttText(vttText: string): SpriteFrame[] { 20 + try { 21 + const frames: SpriteFrame[] = [] 22 + const lines = vttText.split('\n') 23 + 24 + let i = 0 25 + while (i < lines.length) { 26 + const line = lines[i].trim() 27 + 28 + // Look for timestamp line: 00:00:00.000 --> 00:01:00.000 29 + const timeMatch = line.match(/(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})/) 30 + if (timeMatch) { 31 + const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000 32 + const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000 33 + 34 + // Next line should have the sprite coordinates 35 + i++ 36 + if (i < lines.length) { 37 + const coordLine = lines[i].trim() 38 + const coordMatch = coordLine.match(/#xywh=(\d+),(\d+),(\d+),(\d+)/) 39 + if (coordMatch) { 40 + frames.push({ 41 + start, 42 + end, 43 + x: parseInt(coordMatch[1]), 44 + y: parseInt(coordMatch[2]), 45 + w: parseInt(coordMatch[3]), 46 + h: parseInt(coordMatch[4]), 47 + }) 48 + } 49 + } 50 + } 51 + i++ 52 + } 53 + 54 + return frames 55 + } catch { 56 + return [] 57 + } 58 + } 59 + 60 + export const videoCardStyles = ` 61 + .video-card { 62 + cursor: pointer; 63 + } 64 + 65 + .video-card-media { 66 + aspect-ratio: 16 / 10; 67 + border-radius: 8px; 68 + overflow: hidden; 69 + margin-bottom: 1rem; 70 + background: var(--bg-secondary); 71 + position: relative; 72 + } 73 + 74 + .video-card-img { 75 + width: 100%; 76 + height: 100%; 77 + object-fit: cover; 78 + transition: transform 0.4s ease; 79 + } 80 + 81 + .video-card:hover .video-card-img { 82 + transform: scale(1.05); 83 + } 84 + 85 + .video-card-duration { 86 + position: absolute; 87 + bottom: 0.75rem; 88 + right: 0.75rem; 89 + padding: 0.25rem 0.5rem; 90 + background: rgba(0,0,0,0.8); 91 + color: #fff; 92 + font-size: 0.75rem; 93 + font-weight: 500; 94 + border-radius: 4px; 95 + z-index: 2; 96 + } 97 + 98 + .video-card-preview { 99 + position: absolute; 100 + inset: 0; 101 + background-size: cover; 102 + background-repeat: no-repeat; 103 + opacity: 0; 104 + transition: opacity 0.2s ease; 105 + z-index: 1; 106 + } 107 + 108 + .video-card-media:hover .video-card-preview { 109 + opacity: 1; 110 + } 111 + 112 + .video-card-media:hover .video-card-img { 113 + opacity: 0; 114 + } 115 + 116 + .video-card-preview-time { 117 + position: absolute; 118 + bottom: 0.75rem; 119 + left: 0.75rem; 120 + padding: 0.25rem 0.5rem; 121 + background: rgba(0,0,0,0.8); 122 + color: #fff; 123 + font-size: 0.75rem; 124 + font-weight: 500; 125 + border-radius: 4px; 126 + z-index: 3; 127 + } 128 + 129 + .video-card-seek { 130 + position: absolute; 131 + bottom: 0; 132 + left: 0; 133 + right: 0; 134 + height: 4px; 135 + background: rgba(255,255,255,0.2); 136 + z-index: 4; 137 + opacity: 0; 138 + transition: opacity 0.2s, height 0.2s; 139 + } 140 + 141 + .video-card-media:hover .video-card-seek { 142 + opacity: 1; 143 + } 144 + 145 + .video-card-seek-progress { 146 + height: 100%; 147 + background: var(--accent); 148 + border-radius: 0 2px 2px 0; 149 + transition: width 0.05s linear; 150 + position: relative; 151 + } 152 + 153 + .video-card-seek-progress::after { 154 + content: ''; 155 + position: absolute; 156 + right: -4px; 157 + top: 50%; 158 + transform: translateY(-50%); 159 + width: 8px; 160 + height: 8px; 161 + background: #fff; 162 + border-radius: 50%; 163 + box-shadow: 0 1px 3px rgba(0,0,0,0.3); 164 + opacity: 0; 165 + transition: opacity 0.15s; 166 + } 167 + 168 + .video-card-media:hover .video-card-seek-progress::after { 169 + opacity: 1; 170 + } 171 + 172 + .video-card-seek-hint { 173 + position: absolute; 174 + top: 50%; 175 + left: 50%; 176 + transform: translate(-50%, -50%); 177 + padding: 0.5rem 0.75rem; 178 + background: rgba(0,0,0,0.7); 179 + color: #fff; 180 + font-size: 0.75rem; 181 + font-weight: 500; 182 + border-radius: 4px; 183 + z-index: 5; 184 + opacity: 0; 185 + transition: opacity 0.3s; 186 + pointer-events: none; 187 + white-space: nowrap; 188 + } 189 + 190 + .video-card-media:hover .video-card-seek-hint { 191 + opacity: 1; 192 + animation: fadeOutHint 2s ease-out forwards; 193 + } 194 + 195 + @keyframes fadeOutHint { 196 + 0%, 50% { opacity: 1; } 197 + 100% { opacity: 0; } 198 + } 199 + 200 + .video-card-fallback { 201 + width: 100%; 202 + height: 100%; 203 + display: flex; 204 + flex-direction: column; 205 + align-items: center; 206 + justify-content: center; 207 + background: var(--card-bg); 208 + color: var(--text); 209 + gap: 0.75rem; 210 + } 211 + 212 + .video-card-fallback-icon { 213 + font-size: 2rem; 214 + opacity: 0.5; 215 + } 216 + 217 + .video-card-fallback-text { 218 + font-family: 'Instrument Serif', Georgia, serif; 219 + font-size: 0.875rem; 220 + text-align: center; 221 + padding: 0 1rem; 222 + line-height: 1.3; 223 + opacity: 0.7; 224 + display: -webkit-box; 225 + -webkit-line-clamp: 2; 226 + -webkit-box-orient: vertical; 227 + overflow: hidden; 228 + } 229 + 230 + .video-card-title { 231 + font-family: 'Instrument Serif', Georgia, serif; 232 + font-size: 1.375rem; 233 + font-weight: 400; 234 + line-height: 1.3; 235 + margin: 0 0 0.5rem; 236 + } 237 + 238 + .video-card-meta { 239 + display: flex; 240 + align-items: center; 241 + gap: 0.75rem; 242 + font-size: 0.8125rem; 243 + color: var(--text-muted); 244 + } 245 + 246 + .video-card-creator { 247 + display: flex; 248 + align-items: center; 249 + gap: 0.5rem; 250 + cursor: pointer; 251 + transition: opacity 0.2s; 252 + } 253 + 254 + .video-card-creator:hover { 255 + opacity: 0.7; 256 + } 257 + 258 + .video-card-avatar { 259 + width: 24px; 260 + height: 24px; 261 + border-radius: 50%; 262 + object-fit: cover; 263 + background: var(--border); 264 + } 265 + 266 + .video-card-avatar-fallback { 267 + width: 24px; 268 + height: 24px; 269 + border-radius: 50%; 270 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 271 + display: flex; 272 + align-items: center; 273 + justify-content: center; 274 + font-size: 0.625rem; 275 + font-weight: 600; 276 + color: #fff; 277 + } 278 + 279 + .video-card-creator-name { 280 + font-weight: 500; 281 + color: var(--text); 282 + } 283 + 284 + .video-card-date { 285 + color: var(--text-muted); 286 + } 287 + ` 288 + 289 + interface VideoCardProps { 290 + video: Video 291 + metadata?: VideoMetadata 292 + profile?: Profile 293 + onClick: (startTime?: number) => void 294 + showCreator?: boolean 295 + } 296 + 297 + function ThumbnailFallback({ title }: { title: string }) { 298 + return ( 299 + <div className="video-card-fallback"> 300 + <span className="video-card-fallback-icon">▶</span> 301 + <span className="video-card-fallback-text">{title}</span> 302 + </div> 303 + ) 304 + } 305 + 306 + export function VideoCard({ video, metadata, profile, onClick, showCreator = true }: VideoCardProps) { 307 + const mediaRef = useRef<HTMLDivElement>(null) 308 + const [previewFrame, setPreviewFrame] = useState<number | null>(null) 309 + const [frames, setFrames] = useState<SpriteFrame[]>([]) 310 + 311 + const thumbnail = metadata?.thumbnailUrl || null 312 + const spriteSheet = metadata?.spriteUrl || null 313 + 314 + // Fetch and parse VTT for sprite coordinates 315 + useEffect(() => { 316 + if (!metadata?.vttUrl) { 317 + setFrames([]) 318 + return 319 + } 320 + 321 + fetch(metadata.vttUrl) 322 + .then(res => res.ok ? res.text() : Promise.reject('Failed to fetch VTT')) 323 + .then(vttText => setFrames(parseVttText(vttText))) 324 + .catch(() => setFrames([])) 325 + }, [metadata?.vttUrl]) 326 + 327 + const creatorName = profile?.displayName || profile?.handle || null 328 + 329 + const handleCreatorClick = (e: React.MouseEvent) => { 330 + e.stopPropagation() 331 + if (video.creator) { 332 + navigate(`/profile/${video.creator}`) 333 + } 334 + } 335 + 336 + const handleMouseMove = useCallback((e: React.MouseEvent) => { 337 + if (!mediaRef.current || frames.length === 0) return 338 + const rect = mediaRef.current.getBoundingClientRect() 339 + const x = e.clientX - rect.left 340 + const progress = Math.max(0, Math.min(1, x / rect.width)) 341 + const frameIndex = Math.floor(progress * frames.length) 342 + setPreviewFrame(Math.min(frameIndex, frames.length - 1)) 343 + }, [frames]) 344 + 345 + const handleMouseLeave = useCallback(() => { 346 + setPreviewFrame(null) 347 + }, []) 348 + 349 + const currentFrame = previewFrame !== null ? frames[previewFrame] : null 350 + const previewTime = currentFrame ? currentFrame.start : 0 351 + const seekProgress = previewFrame !== null && frames.length > 0 352 + ? ((previewFrame + 1) / frames.length) * 100 353 + : 0 354 + const hasPreview = spriteSheet && frames.length > 0 355 + 356 + // Calculate background position for sprite (4 cols x 2 rows grid) 357 + const spriteStyle = currentFrame && spriteSheet ? (() => { 358 + const cols = 4 359 + const rows = 2 360 + const col = Math.round(currentFrame.x / currentFrame.w) 361 + const row = Math.round(currentFrame.y / currentFrame.h) 362 + const xPercent = cols > 1 ? (col / (cols - 1)) * 100 : 0 363 + const yPercent = rows > 1 ? (row / (rows - 1)) * 100 : 0 364 + return { 365 + backgroundImage: `url(${spriteSheet})`, 366 + backgroundPosition: `${xPercent}% ${yPercent}%`, 367 + backgroundSize: `${cols * 100}% ${rows * 100}%`, 368 + } 369 + })() : {} 370 + 371 + const handleClick = useCallback(() => { 372 + onClick(previewFrame !== null ? previewTime : undefined) 373 + }, [onClick, previewFrame, previewTime]) 374 + 375 + return ( 376 + <article className="video-card" onClick={handleClick}> 377 + <div 378 + ref={mediaRef} 379 + className="video-card-media" 380 + onMouseMove={handleMouseMove} 381 + onMouseLeave={handleMouseLeave} 382 + > 383 + {thumbnail ? ( 384 + <img className="video-card-img" src={thumbnail} alt="" /> 385 + ) : ( 386 + <ThumbnailFallback title={video.title} /> 387 + )} 388 + {hasPreview && previewFrame !== null && ( 389 + <> 390 + <div className="video-card-preview" style={spriteStyle} /> 391 + <span className="video-card-preview-time"> 392 + {formatDuration(previewTime * 1_000_000_000)} 393 + </span> 394 + </> 395 + )} 396 + {hasPreview && ( 397 + <> 398 + <div className="video-card-seek"> 399 + <div 400 + className="video-card-seek-progress" 401 + style={{ width: `${seekProgress}%` }} 402 + /> 403 + </div> 404 + {previewFrame === null && ( 405 + <span className="video-card-seek-hint">Hover to preview</span> 406 + )} 407 + </> 408 + )} 409 + <span className="video-card-duration">{formatDuration(video.duration)}</span> 410 + </div> 411 + <h3 className="video-card-title">{video.title}</h3> 412 + <div className="video-card-meta"> 413 + {showCreator && profile && ( 414 + <div className="video-card-creator" onClick={handleCreatorClick}> 415 + {profile.avatar ? ( 416 + <img className="video-card-avatar" src={profile.avatar} alt="" /> 417 + ) : ( 418 + <div className="video-card-avatar-fallback"> 419 + {creatorName?.[0]?.toUpperCase() || "?"} 420 + </div> 421 + )} 422 + <span className="video-card-creator-name">{creatorName}</span> 423 + </div> 424 + )} 425 + <span className="video-card-date"> 426 + {new Date(video.createdAt).toLocaleDateString("en-US", { 427 + month: "short", 428 + day: "numeric" 429 + })} 430 + </span> 431 + </div> 432 + </article> 433 + ) 434 + }