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): YouTube-style video player layout with profile and hover preview

- Add YouTube-style watch page with video player and sidebar
- Display creator profile with avatar and display name from Bluesky
- Add hover preview on video cards using sprite sheet animation
- Fix video container layout shift with fixed 16:9 aspect ratio
- Add related videos sidebar

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

+490 -86
+206 -26
apps/web/src/App.css
··· 93 93 background: var(--accent-hover, #535bf2); 94 94 } 95 95 96 - /* Video Player */ 97 - .video-player { 98 - max-width: 1400px; 96 + /* Watch Page - YouTube Style Layout */ 97 + .watch-page { 98 + display: grid; 99 + grid-template-columns: 1fr 380px; 100 + gap: 1.5rem; 101 + max-width: 1800px; 99 102 margin: 0 auto; 100 - padding: 1rem; 103 + padding: 1rem 2rem; 101 104 } 102 105 103 - .video-header { 104 - display: flex; 105 - align-items: center; 106 - gap: 1rem; 107 - margin-bottom: 1rem; 108 - flex-wrap: wrap; 109 - } 110 - 111 - .video-header h2 { 112 - flex: 1; 113 - margin: 0; 114 - font-size: 1.25rem; 115 - font-weight: 500; 106 + .watch-main { 107 + min-width: 0; 116 108 } 117 109 118 110 .back-button { 111 + display: inline-flex; 112 + align-items: center; 113 + gap: 0.5rem; 119 114 padding: 0.5rem 1rem; 115 + margin-bottom: 1rem; 120 116 background: transparent; 121 117 border: 1px solid var(--border, #333); 122 118 border-radius: 6px; ··· 129 125 background: var(--card-bg, #1a1a1a); 130 126 } 131 127 128 + .video-container { 129 + position: relative; 130 + width: 100%; 131 + aspect-ratio: 16 / 9; 132 + background: #000; 133 + border-radius: 12px; 134 + overflow: hidden; 135 + } 136 + 137 + .video-element { 138 + position: absolute; 139 + top: 0; 140 + left: 0; 141 + width: 100%; 142 + height: 100%; 143 + object-fit: contain; 144 + } 145 + 146 + .error-overlay { 147 + position: absolute; 148 + top: 1rem; 149 + left: 1rem; 150 + right: 1rem; 151 + background: rgba(255, 68, 68, 0.9); 152 + color: white; 153 + padding: 0.75rem 1rem; 154 + border-radius: 8px; 155 + z-index: 10; 156 + } 157 + 158 + .video-details { 159 + padding: 1rem 0; 160 + } 161 + 162 + .video-title { 163 + font-size: 1.25rem; 164 + font-weight: 600; 165 + margin: 0 0 0.75rem; 166 + line-height: 1.4; 167 + } 168 + 169 + .video-meta { 170 + display: flex; 171 + align-items: center; 172 + gap: 1rem; 173 + margin-bottom: 1rem; 174 + color: var(--text-muted, #888); 175 + font-size: 0.875rem; 176 + } 177 + 132 178 .quality-select { 133 - padding: 0.5rem 1rem; 179 + padding: 0.375rem 0.75rem; 134 180 background: var(--card-bg, #1a1a1a); 135 181 border: 1px solid var(--border, #333); 136 182 border-radius: 6px; ··· 139 185 cursor: pointer; 140 186 } 141 187 142 - .video-element { 143 - width: 100%; 144 - max-height: 80vh; 145 - background: #000; 188 + .creator-info { 189 + display: flex; 190 + align-items: center; 191 + gap: 1rem; 192 + padding: 1rem; 193 + background: var(--card-bg, #1a1a1a); 146 194 border-radius: 12px; 147 195 } 148 196 149 - .error-message { 150 - background: #ff4444; 197 + .creator-avatar, 198 + .creator-avatar-img { 199 + width: 48px; 200 + height: 48px; 201 + border-radius: 50%; 202 + flex-shrink: 0; 203 + } 204 + 205 + .creator-avatar { 206 + background: linear-gradient(135deg, #646cff, #535bf2); 207 + display: flex; 208 + align-items: center; 209 + justify-content: center; 210 + font-size: 1.25rem; 211 + font-weight: 600; 151 212 color: white; 152 - padding: 0.75rem 1rem; 213 + } 214 + 215 + .creator-avatar-img { 216 + object-fit: cover; 217 + } 218 + 219 + .creator-details { 220 + display: flex; 221 + flex-direction: column; 222 + gap: 0.25rem; 223 + } 224 + 225 + .creator-link { 226 + color: inherit; 227 + text-decoration: none; 228 + font-weight: 500; 229 + } 230 + 231 + .creator-link:hover { 232 + color: var(--accent, #646cff); 233 + } 234 + 235 + .creator-handle { 236 + font-size: 0.875rem; 237 + color: var(--text-muted, #888); 238 + } 239 + 240 + /* Sidebar */ 241 + .watch-sidebar { 242 + min-width: 0; 243 + } 244 + 245 + .sidebar-title { 246 + font-size: 1rem; 247 + font-weight: 600; 248 + margin: 0 0 1rem; 249 + } 250 + 251 + .related-videos { 252 + display: flex; 253 + flex-direction: column; 254 + gap: 0.75rem; 255 + } 256 + 257 + .related-video-card { 258 + display: grid; 259 + grid-template-columns: 168px 1fr; 260 + gap: 0.75rem; 261 + cursor: pointer; 153 262 border-radius: 8px; 154 - margin-bottom: 1rem; 263 + transition: background 0.2s; 264 + } 265 + 266 + .related-video-card:hover { 267 + background: var(--card-bg, #1a1a1a); 268 + } 269 + 270 + .related-thumbnail { 271 + aspect-ratio: 16 / 9; 272 + background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%); 273 + border-radius: 8px; 274 + display: flex; 275 + align-items: flex-end; 276 + justify-content: flex-end; 277 + padding: 0.25rem; 278 + } 279 + 280 + .related-thumbnail .duration { 281 + background: rgba(0, 0, 0, 0.8); 282 + padding: 0.125rem 0.375rem; 283 + border-radius: 4px; 284 + font-size: 0.75rem; 285 + font-weight: 500; 286 + } 287 + 288 + .related-info { 289 + padding: 0.25rem 0; 290 + min-width: 0; 291 + } 292 + 293 + .related-info h4 { 294 + font-size: 0.875rem; 295 + font-weight: 500; 296 + margin: 0 0 0.25rem; 297 + line-height: 1.4; 298 + display: -webkit-box; 299 + -webkit-line-clamp: 2; 300 + -webkit-box-orient: vertical; 301 + overflow: hidden; 302 + } 303 + 304 + .related-info p { 305 + font-size: 0.75rem; 306 + color: var(--text-muted, #888); 307 + margin: 0; 308 + } 309 + 310 + /* Responsive */ 311 + @media (max-width: 1100px) { 312 + .watch-page { 313 + grid-template-columns: 1fr; 314 + padding: 1rem; 315 + } 316 + 317 + .watch-sidebar { 318 + border-top: 1px solid var(--border, #333); 319 + padding-top: 1.5rem; 320 + } 321 + 322 + .related-videos { 323 + display: grid; 324 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 325 + gap: 1rem; 326 + } 327 + 328 + .related-video-card { 329 + grid-template-columns: 1fr; 330 + } 331 + 332 + .related-thumbnail { 333 + width: 100%; 334 + } 155 335 } 156 336 157 337 /* Error state for video list */
+11 -4
apps/web/src/App.tsx
··· 1 - import { useState } from "react" 1 + import { useState, useEffect } from "react" 2 2 import { VideoList } from "./components/VideoList" 3 3 import { VideoPlayer } from "./components/VideoPlayer" 4 - import type { Video } from "./api" 4 + import { listVideos, type Video } from "./api" 5 5 import "./App.css" 6 6 7 7 function App() { 8 8 const [selectedVideo, setSelectedVideo] = useState<Video | null>(null) 9 + const [allVideos, setAllVideos] = useState<Video[]>([]) 10 + 11 + useEffect(() => { 12 + listVideos(50).then((result) => setAllVideos(result.videos)) 13 + }, []) 9 14 10 15 if (selectedVideo) { 16 + const otherVideos = allVideos.filter((v) => v.uri !== selectedVideo.uri) 11 17 return ( 12 18 <VideoPlayer 13 - uri={selectedVideo.uri} 14 - title={selectedVideo.title} 19 + video={selectedVideo} 20 + relatedVideos={otherVideos} 15 21 onBack={() => setSelectedVideo(null)} 22 + onSelectVideo={setSelectedVideo} 16 23 /> 17 24 ) 18 25 }
+22
apps/web/src/api.ts
··· 28 28 audioTracks: Array<{ name: string; default: boolean }> 29 29 } 30 30 31 + export interface VideoMetadata { 32 + thumbnail: string // base64 encoded JPEG (single frame) 33 + spriteSheet: string // base64 encoded JPEG (sprite grid for preview) 34 + vtt: string // base64 encoded WebVTT 35 + } 36 + 37 + export interface Profile { 38 + did: string 39 + handle: string 40 + displayName?: string 41 + avatar?: string 42 + description?: string 43 + } 44 + 31 45 async function callEndpoint<T>(endpoint: string, args: unknown = {}): Promise<T> { 32 46 const url = `${RUNNER_URL}/${BUNDLE_PATH}/${endpoint}` 33 47 const res = await fetch(url, { ··· 57 71 58 72 export async function getVideoStreams(uri: string): Promise<VideoStreamsResult> { 59 73 return callEndpoint("getVideoStreams", { uri }) 74 + } 75 + 76 + export async function getVideoMetadata(uri: string): Promise<VideoMetadata> { 77 + return callEndpoint("videoMetadata", { uri }) 78 + } 79 + 80 + export async function getProfile(did: string): Promise<Profile> { 81 + return callEndpoint("getProfile", { did }) 60 82 } 61 83 62 84 export function getPlaylistUrl(uri: string, quality?: string): string {
+121 -16
apps/web/src/components/VideoList.tsx
··· 1 - import { useEffect, useState } from "react" 2 - import { listVideos, formatDuration, type Video } from "../api" 1 + import { useEffect, useState, useRef } from "react" 2 + import { listVideos, formatDuration, getVideoMetadata, type Video, type VideoMetadata } from "../api" 3 + 4 + // Cached video URI that has metadata ready 5 + const CACHED_VIDEO_URI = "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miafuv53nh2z" 6 + 7 + interface SpriteFrame { 8 + x: number 9 + y: number 10 + w: number 11 + h: number 12 + } 13 + 14 + function parseVtt(vttBase64: string): SpriteFrame[] { 15 + const vtt = atob(vttBase64) 16 + const frames: SpriteFrame[] = [] 17 + 18 + // Parse WebVTT sprite coordinates from lines like: 19 + // sprites.jpg#xywh=0,0,160,90 20 + const lines = vtt.split("\n") 21 + for (const line of lines) { 22 + const match = line.match(/#xywh=(\d+),(\d+),(\d+),(\d+)/) 23 + if (match) { 24 + frames.push({ 25 + x: parseInt(match[1]), 26 + y: parseInt(match[2]), 27 + w: parseInt(match[3]), 28 + h: parseInt(match[4]), 29 + }) 30 + } 31 + } 32 + return frames 33 + } 34 + 35 + interface VideoCardProps { 36 + video: Video 37 + metadata?: VideoMetadata 38 + onSelect: (video: Video) => void 39 + } 40 + 41 + function VideoCard({ video, metadata, onSelect }: VideoCardProps) { 42 + const [isHovering, setIsHovering] = useState(false) 43 + const [frameIndex, setFrameIndex] = useState(0) 44 + const intervalRef = useRef<number | null>(null) 45 + 46 + const thumbnail = metadata ? `data:image/jpeg;base64,${metadata.thumbnail}` : null 47 + const spriteSheet = metadata ? `data:image/jpeg;base64,${metadata.spriteSheet}` : null 48 + const frames = metadata ? parseVtt(metadata.vtt) : [] 49 + 50 + useEffect(() => { 51 + if (isHovering && frames.length > 0) { 52 + intervalRef.current = window.setInterval(() => { 53 + setFrameIndex((prev) => (prev + 1) % frames.length) 54 + }, 400) 55 + } else { 56 + if (intervalRef.current) { 57 + clearInterval(intervalRef.current) 58 + intervalRef.current = null 59 + } 60 + setFrameIndex(0) 61 + } 62 + 63 + return () => { 64 + if (intervalRef.current) { 65 + clearInterval(intervalRef.current) 66 + } 67 + } 68 + }, [isHovering, frames.length]) 69 + 70 + const currentFrame = frames[frameIndex] 71 + 72 + // Sprite sheet is 4 cols x 2 rows (160x90 per frame) 73 + // background-size: 400% 200% makes each frame fill the container 74 + // background-position uses percentages: col/(cols-1)*100, row/(rows-1)*100 75 + const cols = 4 76 + const rows = 2 77 + const col = currentFrame ? currentFrame.x / currentFrame.w : 0 78 + const row = currentFrame ? currentFrame.y / currentFrame.h : 0 79 + const bgPosX = cols > 1 ? (col / (cols - 1)) * 100 : 0 80 + const bgPosY = rows > 1 ? (row / (rows - 1)) * 100 : 0 81 + 82 + const style: React.CSSProperties = isHovering && currentFrame && spriteSheet 83 + ? { 84 + backgroundImage: `url(${spriteSheet})`, 85 + backgroundSize: `${cols * 100}% ${rows * 100}%`, 86 + backgroundPosition: `${bgPosX}% ${bgPosY}%`, 87 + } 88 + : thumbnail 89 + ? { backgroundImage: `url(${thumbnail})`, backgroundSize: "cover", backgroundPosition: "center" } 90 + : {} 91 + 92 + return ( 93 + <div 94 + className="video-card" 95 + onClick={() => onSelect(video)} 96 + onMouseEnter={() => setIsHovering(true)} 97 + onMouseLeave={() => setIsHovering(false)} 98 + > 99 + <div className="video-thumbnail" style={style}> 100 + <span className="duration">{formatDuration(video.duration)}</span> 101 + </div> 102 + <div className="video-info"> 103 + <h3>{video.title}</h3> 104 + <p className="video-date"> 105 + {new Date(video.createdAt).toLocaleDateString()} 106 + </p> 107 + </div> 108 + </div> 109 + ) 110 + } 3 111 4 112 interface VideoListProps { 5 113 onSelect: (video: Video) => void ··· 11 119 const [error, setError] = useState<string | null>(null) 12 120 const [cursor, setCursor] = useState<string | undefined>() 13 121 const [hasMore, setHasMore] = useState(true) 122 + const [cachedMetadata, setCachedMetadata] = useState<VideoMetadata | null>(null) 14 123 15 124 const fetchVideos = async (loadMore = false) => { 16 125 try { ··· 34 143 35 144 useEffect(() => { 36 145 fetchVideos() 146 + 147 + // Fetch metadata for the cached video 148 + getVideoMetadata(CACHED_VIDEO_URI) 149 + .then(setCachedMetadata) 150 + .catch((err) => console.error("Failed to fetch cached metadata:", err)) 37 151 }, []) 38 152 39 153 if (error) { ··· 51 165 52 166 <div className="video-grid"> 53 167 {videos.map((video) => ( 54 - <div 168 + <VideoCard 55 169 key={video.uri} 56 - className="video-card" 57 - onClick={() => onSelect(video)} 58 - > 59 - <div className="video-thumbnail"> 60 - <span className="duration">{formatDuration(video.duration)}</span> 61 - </div> 62 - <div className="video-info"> 63 - <h3>{video.title}</h3> 64 - <p className="video-date"> 65 - {new Date(video.createdAt).toLocaleDateString()} 66 - </p> 67 - </div> 68 - </div> 170 + video={video} 171 + metadata={video.uri === CACHED_VIDEO_URI ? cachedMetadata ?? undefined : undefined} 172 + onSelect={onSelect} 173 + /> 69 174 ))} 70 175 </div> 71 176
+130 -40
apps/web/src/components/VideoPlayer.tsx
··· 1 1 import { useEffect, useRef, useState } from "react" 2 2 import Hls from "hls.js" 3 - import { getPlaylistUrl, getVideoStreams, type StreamInfo } from "../api" 3 + import { getPlaylistUrl, getVideoStreams, getVideoMetadata, getProfile, formatDuration, type StreamInfo, type Video, type Profile } from "../api" 4 4 5 5 interface VideoPlayerProps { 6 - uri: string 7 - title: string 6 + video: Video 7 + relatedVideos: Video[] 8 8 onBack?: () => void 9 + onSelectVideo: (video: Video) => void 9 10 } 10 11 11 - export function VideoPlayer({ uri, title, onBack }: VideoPlayerProps) { 12 + export function VideoPlayer({ video, relatedVideos, onBack, onSelectVideo }: VideoPlayerProps) { 12 13 const videoRef = useRef<HTMLVideoElement>(null) 13 14 const hlsRef = useRef<Hls | null>(null) 14 15 const [streams, setStreams] = useState<StreamInfo[]>([]) 15 16 const [selectedQuality, setSelectedQuality] = useState<string | undefined>() 16 17 const [error, setError] = useState<string | null>(null) 18 + const [thumbnail, setThumbnail] = useState<string | null>(null) 19 + const [creatorProfile, setCreatorProfile] = useState<Profile | null>(null) 17 20 18 - // Fetch available streams 21 + const { uri, title, creator, createdAt } = video 22 + 23 + // Fetch available streams and metadata 19 24 useEffect(() => { 20 25 getVideoStreams(uri) 21 26 .then((result) => { ··· 24 29 .catch((err) => { 25 30 console.error("Failed to fetch streams:", err) 26 31 }) 27 - }, [uri]) 32 + 33 + // Fetch thumbnail (async, non-blocking) 34 + getVideoMetadata(uri) 35 + .then((metadata) => { 36 + setThumbnail(`data:image/jpeg;base64,${metadata.thumbnail}`) 37 + }) 38 + .catch((err) => { 39 + console.error("Failed to fetch metadata:", err) 40 + }) 41 + 42 + // Fetch creator profile 43 + if (creator) { 44 + getProfile(creator) 45 + .then(setCreatorProfile) 46 + .catch((err) => { 47 + console.error("Failed to fetch profile:", err) 48 + }) 49 + } 50 + }, [uri, creator]) 28 51 29 52 // Set up HLS player 30 53 useEffect(() => { 31 - const video = videoRef.current 32 - if (!video) return 54 + const videoEl = videoRef.current 55 + if (!videoEl) return 33 56 34 57 const playlistUrl = getPlaylistUrl(uri, selectedQuality) 35 58 ··· 47 70 }) 48 71 49 72 hls.loadSource(playlistUrl) 50 - hls.attachMedia(video) 73 + hls.attachMedia(videoEl) 51 74 52 75 hls.on(Hls.Events.MANIFEST_PARSED, () => { 53 - video.play().catch(() => { 76 + videoEl.play().catch(() => { 54 77 // Autoplay blocked, user will need to click 55 78 }) 56 79 }) ··· 75 98 }) 76 99 77 100 hlsRef.current = hls 78 - } else if (video.canPlayType("application/vnd.apple.mpegurl")) { 101 + } else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) { 79 102 // Native HLS support (Safari) 80 - video.src = playlistUrl 81 - video.addEventListener("loadedmetadata", () => { 82 - video.play().catch(() => {}) 103 + videoEl.src = playlistUrl 104 + videoEl.addEventListener("loadedmetadata", () => { 105 + videoEl.play().catch(() => {}) 83 106 }) 84 107 } else { 85 108 setError("HLS is not supported in this browser") ··· 93 116 } 94 117 }, [uri, selectedQuality]) 95 118 119 + const displayName = creatorProfile?.displayName || creatorProfile?.handle || creator?.slice(0, 20) + "..." || "Unknown" 120 + const avatarUrl = creatorProfile?.avatar 121 + const profileLink = creatorProfile 122 + ? `https://bsky.app/profile/${creatorProfile.handle}` 123 + : creator 124 + ? `https://bsky.app/profile/${creator}` 125 + : "#" 126 + 96 127 return ( 97 - <div className="video-player"> 98 - <div className="video-header"> 128 + <div className="watch-page"> 129 + <div className="watch-main"> 99 130 {onBack && ( 100 131 <button className="back-button" onClick={onBack}> 101 - Back 132 + ← Back to videos 102 133 </button> 103 134 )} 104 - <h2>{title}</h2> 105 - {streams.length > 1 && ( 106 - <select 107 - className="quality-select" 108 - value={selectedQuality || ""} 109 - onChange={(e) => setSelectedQuality(e.target.value || undefined)} 110 - > 111 - <option value="">Auto</option> 112 - {streams.map((s) => ( 113 - <option key={s.quality} value={s.quality}> 114 - {s.quality} ({Math.round(s.bandwidth / 1000)}kbps) 115 - </option> 116 - ))} 117 - </select> 118 - )} 119 - </div> 120 135 121 - {error && <div className="error-message">{error}</div>} 136 + <div className="video-container"> 137 + {error && <div className="error-overlay">{error}</div>} 138 + <video 139 + ref={videoRef} 140 + controls 141 + playsInline 142 + className="video-element" 143 + poster={thumbnail || undefined} 144 + /> 145 + </div> 122 146 123 - <video 124 - ref={videoRef} 125 - controls 126 - playsInline 127 - className="video-element" 128 - /> 147 + <div className="video-details"> 148 + <h1 className="video-title">{title}</h1> 149 + 150 + <div className="video-meta"> 151 + <span className="video-date"> 152 + {new Date(createdAt).toLocaleDateString("en-US", { 153 + year: "numeric", 154 + month: "long", 155 + day: "numeric", 156 + })} 157 + </span> 158 + {streams.length > 1 && ( 159 + <select 160 + className="quality-select" 161 + value={selectedQuality || ""} 162 + onChange={(e) => setSelectedQuality(e.target.value || undefined)} 163 + > 164 + <option value="">Auto Quality</option> 165 + {streams.map((s) => ( 166 + <option key={s.quality} value={s.quality}> 167 + {s.quality} ({Math.round(s.bandwidth / 1000)}kbps) 168 + </option> 169 + ))} 170 + </select> 171 + )} 172 + </div> 173 + 174 + <div className="creator-info"> 175 + {avatarUrl ? ( 176 + <img src={avatarUrl} alt={displayName} className="creator-avatar-img" /> 177 + ) : ( 178 + <div className="creator-avatar"> 179 + {displayName[0]?.toUpperCase() || "?"} 180 + </div> 181 + )} 182 + <div className="creator-details"> 183 + <a 184 + href={profileLink} 185 + target="_blank" 186 + rel="noopener noreferrer" 187 + className="creator-link" 188 + > 189 + {displayName} 190 + </a> 191 + {creatorProfile?.handle && ( 192 + <span className="creator-handle">@{creatorProfile.handle}</span> 193 + )} 194 + </div> 195 + </div> 196 + </div> 197 + </div> 198 + 199 + <aside className="watch-sidebar"> 200 + <h3 className="sidebar-title">More from AtmosphereConf</h3> 201 + <div className="related-videos"> 202 + {relatedVideos.slice(0, 10).map((v) => ( 203 + <div 204 + key={v.uri} 205 + className="related-video-card" 206 + onClick={() => onSelectVideo(v)} 207 + > 208 + <div className="related-thumbnail"> 209 + <span className="duration">{formatDuration(v.duration)}</span> 210 + </div> 211 + <div className="related-info"> 212 + <h4>{v.title}</h4> 213 + <p>{new Date(v.createdAt).toLocaleDateString()}</p> 214 + </div> 215 + </div> 216 + ))} 217 + </div> 218 + </aside> 129 219 </div> 130 220 ) 131 221 }