Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
38 kB · 1269 lines
TSX
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270import { useState, useCallback, useEffect, useRef } from 'react';import { createPortal } from 'react-dom';import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';import { faPlay, faPause, faVolumeXmark, faVolumeLow, faVolumeHigh, faXmark, faChevronLeft, faChevronRight, faExpand, faCompress} from '@fortawesome/free-solid-svg-icons';import Hls from 'hls.js';import type { EmbedView, EmbedViewRecord, Facet } from '../api/types';import { useVideoAutoplay } from './useVideoAutoplay';import { useDefaultVideoVolume } from '../settings/useDefaultVideoVolume';import { useMuteBlock } from './muteBlockContext';import { computeGainForPlaylist, getCachedGain } from '../utils/videoGain';import PdsFavicon from './PdsFavicon';import PostBody from '../utils/renderPostBody';import { useImageProxy, useProxiedUrl } from '../settings/ImageProxyProvider';import './PostEmbeds.css';import {lightboxOpenRef} from "../utils/lightboxState.ts";import { preloadImage } from '../utils/imagePreload';
function isImagesView(e: EmbedView): e is Extract<EmbedView, { $type: 'app.bsky.embed.images#view' }> { return (e as { $type?: string }).$type === 'app.bsky.embed.images#view';}
function isExternalView(e: EmbedView): e is Extract<EmbedView, { $type: 'app.bsky.embed.external#view' }> { return (e as { $type?: string }).$type === 'app.bsky.embed.external#view';}
function isRecordView(e: EmbedView): e is Extract<EmbedView, { $type: 'app.bsky.embed.record#view' }> { return (e as { $type?: string }).$type === 'app.bsky.embed.record#view';}
function isRecordWithMediaView(e: EmbedView): e is Extract<EmbedView, { $type: 'app.bsky.embed.recordWithMedia#view' }> { return (e as { $type?: string }).$type === 'app.bsky.embed.recordWithMedia#view';}
function isVideoView(e: EmbedView): e is Extract<EmbedView, { $type: 'app.bsky.embed.video#view' }> { return (e as { $type?: string }).$type === 'app.bsky.embed.video#view';}
const GIF_DOMAINS = [ 'media.tenor.com', 'c.tenor.com', 'tenor.com', 'media.giphy.com', 'giphy.com', 'i.giphy.com',];
function isGifUrl(url: string): boolean { try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); return GIF_DOMAINS.some( (d) => hostname === d || hostname.endsWith('.' + d), ); } catch { return false; }}
function isExternalGif(external: { uri: string; title: string; description: string; thumb?: string }): boolean { if (isGifUrl(external.uri)) return true; if (external.thumb && isGifUrl(external.thumb)) return true; const pathLower = external.uri.toLowerCase(); if (pathLower.includes('.gif')) return true; return false;}
interface EmbedImage { thumb: string; fullsize: string; alt: string; aspectRatio?: { width: number; height: number };}
interface LightboxProps { images: EmbedImage[]; initialIndex: number; onClose: () => void;}
const MIN_ZOOM = 1;const MAX_ZOOM = 10;const ZOOM_STEP = 0.15;
function Lightbox({ images, initialIndex, onClose }: LightboxProps) { const [index, setIndex] = useState(initialIndex); const [loaded, setLoaded] = useState<Set<number>>(new Set());
const [zoom, setZoom] = useState(MIN_ZOOM); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isPanning, setIsPanning] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 }); const didDragRef = useRef(false); const backdropRef = useRef<HTMLDivElement>(null); const zoomResetRef = useRef(MIN_ZOOM);
useEffect(() => { zoomResetRef.current = zoom; }, [zoom]);
const resetView = useCallback(() => { setZoom(MIN_ZOOM); setPanX(0); setPanY(0); }, []);
const goTo = useCallback( (i: number) => { setIndex(((i % images.length) + images.length) % images.length); resetView(); }, [images.length, resetView], );
const prev = useCallback(() => goTo(index - 1), [goTo, index]); const next = useCallback(() => goTo(index + 1), [goTo, index]);
useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); else if (e.key === 'ArrowLeft') prev(); else if (e.key === 'ArrowRight') next(); else if (e.key === '0') resetView(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [onClose, prev, next, resetView]);
useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, []);
// Preload all sibling images when the lightbox opens, and preload // adjacent images whenever the active index changes. useEffect(() => { if (images.length <= 1) return; images.forEach((img) => preloadImage(img.fullsize)); }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (images.length <= 1) return; const prevIdx = (index - 1 + images.length) % images.length; const nextIdx = (index + 1) % images.length; preloadImage(images[prevIdx].fullsize); preloadImage(images[nextIdx].fullsize); }, [index, images]);
const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); e.stopPropagation();
const backdrop = backdropRef.current; if (!backdrop) return;
const rect = backdrop.getBoundingClientRect();
const cursorX = e.clientX - rect.left - rect.width / 2; const cursorY = e.clientY - rect.top - rect.height / 2;
const currentZoom = zoomResetRef.current; const direction = e.deltaY < 0 ? 1 : -1; const factor = 1 + ZOOM_STEP * direction; const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, currentZoom * factor));
if (newZoom === currentZoom) return;
const ratio = newZoom / currentZoom;
setPanX((prev) => cursorX - ratio * (cursorX - prev)); setPanY((prev) => cursorY - ratio * (cursorY - prev)); setZoom(newZoom); }, []);
useEffect(() => { if (!isPanning) return;
const onMouseMove = (e: MouseEvent) => { e.preventDefault(); const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { didDragRef.current = true; } setPanX(dragStartRef.current.panX + dx); setPanY(dragStartRef.current.panY + dy); };
const onMouseUp = () => { setIsPanning(false); };
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); document.body.style.cursor = 'grabbing';
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; }; }, [isPanning]);
const handleMouseDown = useCallback( (e: React.MouseEvent) => {
if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); didDragRef.current = false; dragStartRef.current = { x: e.clientX, y: e.clientY, panX, panY }; setIsPanning(true); }, [panX, panY], );
const handleDoubleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (zoom > MIN_ZOOM) { resetView(); } else {
const backdrop = backdropRef.current; if (!backdrop) return; const rect = backdrop.getBoundingClientRect(); const cursorX = e.clientX - rect.left - rect.width / 2; const cursorY = e.clientY - rect.top - rect.height / 2; const newZoom = 3; const ratio = newZoom / MIN_ZOOM; setPanX(cursorX - ratio * (cursorX - 0)); setPanY(cursorY - ratio * (cursorY - 0)); setZoom(newZoom); } }, [zoom, resetView], );
const handleBackdropClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault();
if (didDragRef.current) { didDragRef.current = false; return; }
if (zoom > MIN_ZOOM) { resetView(); return; } onClose(); }, [zoom, resetView, onClose], );
const img = images[index]; const hasPrev = images.length > 1; const hasNext = images.length > 1; const cursorStyle = isPanning ? 'grabbing' : zoom > MIN_ZOOM ? 'grab' : 'default';
return ( <div ref={backdropRef} className="lightbox-backdrop" onClick={handleBackdropClick} onWheel={handleWheel} onMouseDown={handleMouseDown} onDoubleClick={handleDoubleClick} role="dialog" aria-modal="true" aria-label="Image viewer" style={{ cursor: cursorStyle }} > <button className="lightbox-close" onClick={(e) => { e.stopPropagation(); onClose(); }} aria-label="Close" > <FontAwesomeIcon icon={faXmark} /> </button>
{images.length > 1 && ( <div className="lightbox-counter"> {index + 1} / {images.length} </div> )}
{zoom > MIN_ZOOM && ( <div className="lightbox-zoom-indicator" onClick={(e) => e.stopPropagation()}> {Math.round(zoom * 100)}% </div> )}
<div className="lightbox-image-container" onClick={(e) => e.stopPropagation()} style={{ pointerEvents: 'none' }} > <img src={img.fullsize} alt={img.alt} className={`lightbox-image ${loaded.has(index) ? 'lightbox-image--loaded' : ''}`} onLoad={() => setLoaded((prev) => new Set(prev).add(index))} draggable={false} style={{ transform: `translate(${panX}px, ${panY}px) scale(${zoom})`, transition: isPanning ? 'none' : 'transform 0.15s ease-out', }} /> </div>
{hasPrev && ( <button className="lightbox-nav lightbox-nav--prev" onClick={(e) => { e.stopPropagation(); prev(); }} aria-label="Previous image" > <FontAwesomeIcon icon={faChevronLeft} /> </button> )} {hasNext && ( <button className="lightbox-nav lightbox-nav--next" onClick={(e) => { e.stopPropagation(); next(); }} aria-label="Next image" > <FontAwesomeIcon icon={faChevronRight} /> </button> )}
{img.alt && ( <div className="lightbox-alt" onClick={(e) => e.stopPropagation()}> {img.alt} </div> )} </div> );}
interface EmbedImagesProps { images: EmbedImage[];}
function EmbedImageItem({ img }: { img: EmbedImage }) { const { markActiveProxyFailed } = useImageProxy(); const proxiedThumb = useProxiedUrl(img.thumb);
return ( <img src={proxiedThumb} alt={img.alt} className="embed-image" loading="lazy" onError={() => markActiveProxyFailed()} /> );}
function EmbedImages({ images }: EmbedImagesProps) { const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const ownsHistoryEntry = useRef(false);
useEffect(() => { lightboxOpenRef.current = lightboxIndex !== null; }, [lightboxIndex]);
useEffect(() => { if (lightboxIndex !== null) { window.history.pushState({ lightbox: true }, '', '#lightbox'); ownsHistoryEntry.current = true; } }, [lightboxIndex]);
useEffect(() => { const onPopState = () => { if (ownsHistoryEntry.current) { ownsHistoryEntry.current = false; setLightboxIndex(null); } }; window.addEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState); }, []);
const closeLightbox = useCallback(() => { if (ownsHistoryEntry.current) { ownsHistoryEntry.current = false; window.history.back(); } setLightboxIndex(null); }, []);
const gridClass = `embed-images embed-images--${Math.min(images.length, 4)}`;
return ( <> <div className={gridClass}> {images.map((img, i) => ( <button key={i} type="button" className="embed-image-link" onClick={(e) => { e.stopPropagation(); e.preventDefault(); setLightboxIndex(i); }} onMouseEnter={() => preloadImage(img.fullsize)} aria-label={img.alt || `Image ${i + 1} of ${images.length}`} > <EmbedImageItem img={img} /> </button> ))} </div> {lightboxIndex !== null && createPortal( <Lightbox images={images} initialIndex={lightboxIndex} onClose={closeLightbox} />, document.body, )} </> );}
interface EmbedExternalProps { external: { uri: string; title: string; description: string; thumb?: string };}
function EmbedExternal({ external }: EmbedExternalProps) { const { markActiveProxyFailed } = useImageProxy(); const proxiedThumb = useProxiedUrl(external.thumb); return ( <a href={external.uri} target="_blank" rel="noopener noreferrer" className="embed-external" onClick={(e) => e.stopPropagation()}> {external.thumb && ( <img src={proxiedThumb} alt="" className="embed-external-thumb" loading="lazy" onError={() => markActiveProxyFailed()} /> )} <div className="embed-external-info"> <div className="embed-external-title">{external.title}</div> <div className="embed-external-desc">{external.description}</div> <div className="embed-external-url">{new URL(external.uri).hostname}</div> </div> </a> );}
interface EmbedGifProps { external: { uri: string; title: string; description: string; thumb?: string };}
function EmbedGif({ external }: EmbedGifProps) { const { autoplay } = useVideoAutoplay(); // Prefer the URI (the actual animated GIF URL from tenor/giphy) over the // thumb, which may be a static first-frame preview generated by Bluesky. const gifSrc = (isGifUrl(external.uri) ? external.uri : null) || external.thumb || external.uri; const [playing, setPlaying] = useState(autoplay === 'on');
const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); if (!playing) { setPlaying(true); } }, [playing]);
if (autoplay === 'on' || playing) { return ( <a href={external.uri} target="_blank" rel="noopener noreferrer" className="embed-gif" onClick={(e) => e.stopPropagation()} > <img src={gifSrc} alt={external.title || 'GIF'} className="embed-gif-img" loading="lazy" /> </a> ); }
return ( <button type="button" className="embed-gif embed-gif--paused" onClick={handleClick} aria-label="Play GIF" > <img src={gifSrc} alt={external.title || 'GIF'} className="embed-gif-img" loading="lazy" /> <span className="embed-gif-badge">GIF</span> <span className="embed-gif-play" aria-hidden="true"> <FontAwesomeIcon icon={faPlay} /> </span> </button> );}
interface QuotedPostProps { record: EmbedViewRecord; onOpenPost?: (uri: string, authorHandle?: string, authorDid?: string) => void;}
function QuotedPost({ record, onOpenPost }: QuotedPostProps) { const displayName = record.author.displayName ?? record.author.handle; const { isBlocked, isMuted } = useMuteBlock(); const quotedPostRoute = `/profile/${encodeURIComponent(record.author.handle)}/post/${record.uri.split('/').pop()}`; return ( <a href={quotedPostRoute} className="embed-quote" onClick={(e) => { e.preventDefault(); e.stopPropagation(); onOpenPost?.(record.uri, record.author.handle, record.author.did); }} > <div className="embed-quote-header"> {record.author.avatar ? ( <img className="embed-quote-avatar" src={record.author.avatar} alt="" loading="lazy" /> ) : ( <div className="embed-quote-avatar-placeholder">{displayName.charAt(0).toUpperCase()}</div> )} <span className="embed-quote-name">{displayName}</span> <PdsFavicon did={record.author.did} /> <span className="embed-quote-handle">@{record.author.handle}</span> {isBlocked(record.author.did) && <span className="blocked-badge">Blocked</span>} {isMuted(record.author.did) && <span className="muted-badge">Muted</span>} </div> <PostBody text={record.value.text} facets={(record.value as { facets?: Facet[] }).facets} className="embed-quote-text" /> </a> );}
let currentlyPlayingVideo: HTMLVideoElement | null = null;
function registerActiveVideo(video: HTMLVideoElement) { if (currentlyPlayingVideo && currentlyPlayingVideo !== video) { currentlyPlayingVideo.pause(); } currentlyPlayingVideo = video;}
function unregisterActiveVideo(video: HTMLVideoElement) { if (currentlyPlayingVideo === video) { currentlyPlayingVideo = null; }}
function formatTime(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`;}
interface VideoPlayerProps { playlist: string; thumbnail?: string; alt?: string; aspectRatio?: { width: number; height: number };}
function VideoPlayer({ playlist, thumbnail, aspectRatio }: VideoPlayerProps) { const videoRef = useRef<HTMLVideoElement>(null); const hlsRef = useRef<Hls | null>(null); const containerRef = useRef<HTMLDivElement>(null);
const { autoplay: autoplaySetting } = useVideoAutoplay(); const { defaultVolume } = useDefaultVideoVolume();
const audioCtxRef = useRef<AudioContext | null>(null); const gainNodeRef = useRef<GainNode | null>(null); const sourceNodeRef = useRef<MediaElementAudioSourceNode | null>(null);
const autoMutedRef = useRef(false);
const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); const [volume, setVolume] = useState(defaultVolume); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState(0); const [showControls, setShowControls] = useState(true); const [hasStarted, setHasStarted] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const userInteractedRef = useRef(false);
const seekBarRef = useRef<HTMLDivElement>(null); const isDraggingSeekRef = useRef(false);
const volumeBarRef = useRef<HTMLDivElement>(null); const isDraggingVolumeRef = useRef(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Detect if the playlist URL is a direct blob URL (not an HLS playlist). // For suspended accounts, videos are served as raw blobs via sync.getBlob // which are MP4 files — not HLS .m3u8 playlists. These must be played via // video.src directly rather than through HLS.js. const isDirectBlob = !playlist.includes('.m3u8');
useEffect(() => { const video = videoRef.current; if (!video) return;
video.volume = defaultVolume; setVolume(defaultVolume);
// For direct blob URLs (e.g., sync.getBlob from PDS), use video.src directly if (isDirectBlob) { video.src = playlist; return; }
if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = playlist; } else if (Hls.isSupported()) { const hls = new Hls({ startLevel: -1, capLevelToPlayerSize: true, maxBufferLength: 30, maxMaxBufferLength: 60, }); hls.loadSource(playlist); hls.attachMedia(video); hlsRef.current = hls; } else { video.src = playlist; }
return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } }; }, [playlist, defaultVolume, isDirectBlob]);
useEffect(() => { // Skip gain pre-computation for direct blob URLs (not HLS — no playlist to parse) if (isDirectBlob) return; console.log('[normalize][VideoPlayer] mount - starting pre-computation for', playlist); computeGainForPlaylist(playlist).then((gain) => { console.log('[normalize][VideoPlayer] pre-computation resolved, gain:', gain); }); }, [playlist, isDirectBlob]);
useEffect(() => { if (autoplaySetting !== 'on') return;
const container = containerRef.current; const video = videoRef.current; if (!container || !video) return;
const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry) return;
if (entry.isIntersecting) { if (!userInteractedRef.current && video.paused) { video.muted = true; setIsMuted(true); autoMutedRef.current = true; video.play().catch(() => { /* autoplay blocked */ }); setHasStarted(true); } } else { if (!userInteractedRef.current && !video.paused) { video.pause(); autoMutedRef.current = false; } } }, { threshold: 0.5, }, );
observer.observe(container); return () => observer.disconnect(); }, [autoplaySetting]);
useEffect(() => { const container = containerRef.current; const video = videoRef.current; if (!container || !video) return;
const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry) return;
if (!entry.isIntersecting && !video.paused) { video.pause(); setHasStarted(false); } }, { threshold: 0.1 }, );
observer.observe(container); return () => observer.disconnect(); }, []);
useEffect(() => { const video = videoRef.current; if (!video) return;
const onPlay = () => { setIsPlaying(true); registerActiveVideo(video); }; const onPause = () => { setIsPlaying(false); unregisterActiveVideo(video); }; const onTimeUpdate = () => { setCurrentTime(video.currentTime); if (isDraggingSeekRef.current) return; }; const onDurationChange = () => setDuration(video.duration); const onLoadedMetadata = () => setDuration(video.duration); const onProgress = () => { if (video.buffered.length > 0) { setBuffered(video.buffered.end(video.buffered.length - 1)); } }; const onEnded = () => { // With `loop`, the browser restarts automatically but some browsers // still fire `ended` briefly. Don't reset the UI state so the play // button overlay doesn't flash on each loop cycle. if (!video.loop) { setIsPlaying(false); setHasStarted(false); } };
video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('durationchange', onDurationChange); video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('progress', onProgress); video.addEventListener('ended', onEnded);
return () => { video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('durationchange', onDurationChange); video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('progress', onProgress); video.removeEventListener('ended', onEnded); unregisterActiveVideo(video); }; }, []);
useEffect(() => { const onChange = () => { const fsElement = document.fullscreenElement; setIsFullscreen(fsElement === containerRef.current); }; document.addEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange); }, []);
useEffect(() => { if (showControls && isPlaying) { hideTimerRef.current = setTimeout(() => { if (!isDraggingSeekRef.current && !isDraggingVolumeRef.current) { setShowControls(false); } }, 3000); } return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; }, [showControls, isPlaying]);
const normalizedRef = useRef(false);
const setupAudioGraph = useCallback(() => { const video = videoRef.current; if (!video || audioCtxRef.current) return;
try { const ctx = new AudioContext(); const source = ctx.createMediaElementSource(video); const gain = ctx.createGain(); const analyser = ctx.createAnalyser(); analyser.fftSize = 2048;
const cachedGain = getCachedGain(playlist); if (cachedGain !== null) { gain.gain.value = cachedGain; console.log('[normalize][setupAudioGraph] using pre-computed gain:', cachedGain); normalizedRef.current = true; } else { gain.gain.value = 0.7; console.log('[normalize][setupAudioGraph] no cached gain, using default 0.7'); }
source.connect(analyser); analyser.connect(gain); gain.connect(ctx.destination);
audioCtxRef.current = ctx; sourceNodeRef.current = source; gainNodeRef.current = gain;
// Resume the AudioContext — it may start in "suspended" state, which // blocks all audio output through the Web Audio graph even when the // video element itself is unmuted and playing. if (ctx.state === 'suspended') { ctx.resume().catch(() => {}); }
// NOTE: Do NOT call video.play() here. createMediaElementSource() can // briefly pause the video in some browsers, but calling play() here // conflicts with the caller's own play/pause logic and causes // overlapping play() promises (AbortError). The caller is responsible // for enforcing the intended play state after calling this function. } catch { /* empty */ } }, [playlist]);
const ensureAudioSetup = useCallback(() => { setupAudioGraph(); }, [setupAudioGraph]);
useEffect(() => { return () => { if (audioCtxRef.current) { audioCtxRef.current.close().catch(() => {}); audioCtxRef.current = null; } }; }, []);
const togglePlay = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const video = videoRef.current; if (!video) return;
userInteractedRef.current = true;
// Capture intended state BEFORE audio setup, which can briefly pause // the video due to createMediaElementSource(). const wasAutoMutedPlaying = autoMutedRef.current && !video.paused; const wasPausedBeforeSetup = video.paused;
ensureAudioSetup();
if (wasAutoMutedPlaying) { // Auto-muted video: unmute and keep playing, don't toggle play state. video.muted = false; autoMutedRef.current = false; if (video.paused) { video.play().catch(() => {}); } return; }
autoMutedRef.current = false;
// Normal toggle based on pre-setup state. if (wasPausedBeforeSetup) { video.play().catch(() => {}); setHasStarted(true); } else { video.pause(); } }, [ensureAudioSetup]);
const toggleMute = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const video = videoRef.current; if (!video) return;
video.muted = !video.muted; setIsMuted(video.muted); }, []);
const toggleFullscreen = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const container = containerRef.current; if (!container) return;
if (document.fullscreenElement === container) { document.exitFullscreen().catch(() => {}); } else { container.requestFullscreen().catch(() => {}); } }, []);
const setVolumeFromEvent = useCallback((e: MouseEvent | React.MouseEvent) => { const video = videoRef.current; const bar = volumeBarRef.current; if (!video || !bar) return;
const rect = bar.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); video.volume = x; setVolume(x); if (x > 0 && video.muted) { video.muted = false; setIsMuted(false); } if (x === 0) { video.muted = true; setIsMuted(true); } }, []);
const handleVolumeClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { e.stopPropagation(); setVolumeFromEvent(e); }, [setVolumeFromEvent]);
const handleVolumeMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { e.stopPropagation(); isDraggingVolumeRef.current = true; setVolumeFromEvent(e); }, [setVolumeFromEvent]);
const seekFromEvent = useCallback((e: MouseEvent | React.MouseEvent) => { const video = videoRef.current; const bar = seekBarRef.current; if (!video || !bar || !isFinite(video.duration)) return;
const rect = bar.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); video.currentTime = x * video.duration; setCurrentTime(video.currentTime); }, []);
const handleSeekBarClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); seekFromEvent(e); }, [seekFromEvent]);
useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (isDraggingSeekRef.current) { seekFromEvent(e); } if (isDraggingVolumeRef.current) { setVolumeFromEvent(e); } }; const onMouseUp = () => { isDraggingSeekRef.current = false; isDraggingVolumeRef.current = false; };
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; }, [seekFromEvent, setVolumeFromEvent]);
const handleSeekBarMouseDown = useCallback((e: React.MouseEvent) => { e.stopPropagation(); isDraggingSeekRef.current = true; seekFromEvent(e); }, [seekFromEvent]);
const handleMouseMove = useCallback(() => { setShowControls(true); }, []);
const handleVideoClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); const video = videoRef.current; if (!video) return;
userInteractedRef.current = true;
// Capture intended state BEFORE audio setup, which can briefly pause // the video due to createMediaElementSource(). const wasAutoMutedPlaying = autoMutedRef.current && !video.paused; const wasPausedBeforeSetup = video.paused;
ensureAudioSetup();
if (wasAutoMutedPlaying) { // First click on auto-muted video: unmute and keep playing. // This is NOT a play/pause toggle — it only activates audio. video.muted = false; video.volume = defaultVolume; setVolume(defaultVolume); setIsMuted(false); autoMutedRef.current = false; // createMediaElementSource may have paused the video; resume it. // This is the single authoritative play() call for this action. if (video.paused) { video.play().catch(() => {}); } return; }
autoMutedRef.current = false;
// Normal toggle based on pre-setup paused state to avoid the race // where createMediaElementSource briefly paused the video. if (wasPausedBeforeSetup) { video.play().catch(() => {}); setHasStarted(true); } else { video.pause(); } }, [ensureAudioSetup, defaultVolume]);
const volumeIcon = isMuted || volume === 0 ? faVolumeXmark : volume < 0.5 ? faVolumeLow : faVolumeHigh;
const progress = duration > 0 ? currentTime / duration : 0; const bufferedProgress = duration > 0 ? buffered / duration : 0;
return ( <div ref={containerRef} className={`embed-video${isFullscreen ? ' embed-video--fullscreen' : ''}`} style={aspectRatio ? { aspectRatio: `${aspectRatio.width}/${aspectRatio.height}` } : undefined} onMouseMove={handleMouseMove} onMouseLeave={() => { if (isPlaying) setShowControls(false); }} > <video ref={videoRef} poster={thumbnail} preload="metadata" className="embed-video-player" playsInline loop onClick={handleVideoClick} />
{!hasStarted && !isPlaying && ( <button className="embed-video-play-btn" onClick={(e) => { e.stopPropagation(); const video = videoRef.current; if (!video) return;
userInteractedRef.current = true; ensureAudioSetup();
video.volume = defaultVolume; setVolume(defaultVolume);
video.play().catch(() => {}); setHasStarted(true); }} aria-label="Play video" > <FontAwesomeIcon icon={faPlay} /> </button> )}
<div className={`embed-video-controls ${showControls || !isPlaying ? 'embed-video-controls--visible' : ''}`} onClick={(e) => e.stopPropagation()} > <div ref={seekBarRef} className="embed-video-seek" onClick={handleSeekBarClick} onMouseDown={handleSeekBarMouseDown} > <div className="embed-video-seek-buffered" style={{ width: `${bufferedProgress * 100}%` }} /> <div className="embed-video-seek-progress" style={{ width: `${progress * 100}%` }} /> <div className="embed-video-seek-thumb" style={{ left: `${progress * 100}%` }} /> </div>
<div className="embed-video-controls-row"> <button className="embed-video-ctrl-btn" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}> <FontAwesomeIcon icon={isPlaying ? faPause : faPlay} /> </button>
<span className="embed-video-time"> {formatTime(currentTime)} / {formatTime(duration)} </span>
<div style={{ flex: 1 }} />
<button className="embed-video-ctrl-btn" onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}> <FontAwesomeIcon icon={volumeIcon} /> </button> <div ref={volumeBarRef} className="embed-video-volume-slider" onClick={handleVolumeClick} onMouseDown={handleVolumeMouseDown} > <div className="embed-video-volume-fill" style={{ width: `${(isMuted ? 0 : volume) * 100}%` }} /> <div className="embed-video-volume-thumb" style={{ left: `${(isMuted ? 0 : volume) * 100}%` }} /> </div> <button className="embed-video-ctrl-btn" onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}> <FontAwesomeIcon icon={isFullscreen ? faCompress : faExpand} /> </button> </div> </div> </div> );}
function renderMedia(media: EmbedView) { if (isImagesView(media)) return <EmbedImages images={media.images} />; if (isVideoView(media)) return <VideoPlayer playlist={media.playlist} thumbnail={media.thumbnail} alt={media.alt} aspectRatio={media.aspectRatio} />; if (isExternalView(media)) { if (isExternalGif(media.external)) { return <EmbedGif external={media.external} />; } return <EmbedExternal external={media.external} />; } return null;}
interface PostEmbedsProps { embed: EmbedView | undefined; onOpenPost?: (uri: string, authorHandle?: string, authorDid?: string) => void;}
export default function PostEmbeds({ embed, onOpenPost }: PostEmbedsProps) { if (!embed) return null;
if (isImagesView(embed)) { return <EmbedImages images={embed.images} />; }
if (isVideoView(embed)) { return <VideoPlayer playlist={embed.playlist} thumbnail={embed.thumbnail} alt={embed.alt} aspectRatio={embed.aspectRatio} />; }
if (isExternalView(embed)) { if (isExternalGif(embed.external)) { return <EmbedGif external={embed.external} />; } return <EmbedExternal external={embed.external} />; }
if (isRecordView(embed)) { const rec = embed.record; const recType = (rec as { $type?: string }).$type; if (recType === 'app.bsky.embed.record#viewRecord') return <QuotedPost record={rec as unknown as EmbedViewRecord} onOpenPost={onOpenPost} />; if (recType === 'app.bsky.embed.record#viewNotFound') return <div className="embed-unavailable">Post deleted</div>; if (recType === 'app.bsky.embed.record#viewBlocked') return <div className="embed-unavailable">Post by blocked user</div>; return null; }
if (isRecordWithMediaView(embed)) { const rec = embed.record; const innerRec = rec.record; const innerRecType = (innerRec as { $type?: string }).$type; let innerEmbed: React.ReactNode = null; if (innerRecType === 'app.bsky.embed.record#viewRecord') { innerEmbed = <QuotedPost record={innerRec as unknown as EmbedViewRecord} onOpenPost={onOpenPost} />; } else if (innerRecType === 'app.bsky.embed.record#viewNotFound') { innerEmbed = <div className="embed-unavailable">Post deleted</div>; } else if (innerRecType === 'app.bsky.embed.record#viewBlocked') { innerEmbed = <div className="embed-unavailable">Post by blocked user</div>; } return ( <div className="embed-record-with-media"> {renderMedia(embed.media)} {innerEmbed} </div> ); }
return null;}