import { 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 { return (e as { $type?: string }).$type === 'app.bsky.embed.images#view'; } function isExternalView(e: EmbedView): e is Extract { return (e as { $type?: string }).$type === 'app.bsky.embed.external#view'; } function isRecordView(e: EmbedView): e is Extract { return (e as { $type?: string }).$type === 'app.bsky.embed.record#view'; } function isRecordWithMediaView(e: EmbedView): e is Extract { return (e as { $type?: string }).$type === 'app.bsky.embed.recordWithMedia#view'; } function isVideoView(e: EmbedView): e is Extract { 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>(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(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 (
{images.length > 1 && (
{index + 1} / {images.length}
)} {zoom > MIN_ZOOM && (
e.stopPropagation()}> {Math.round(zoom * 100)}%
)}
e.stopPropagation()} style={{ pointerEvents: 'none' }} > {img.alt} 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', }} />
{hasPrev && ( )} {hasNext && ( )} {img.alt && (
e.stopPropagation()}> {img.alt}
)}
); } interface EmbedImagesProps { images: EmbedImage[]; } function EmbedImageItem({ img }: { img: EmbedImage }) { const { markActiveProxyFailed } = useImageProxy(); const proxiedThumb = useProxiedUrl(img.thumb); return ( {img.alt} markActiveProxyFailed()} /> ); } function EmbedImages({ images }: EmbedImagesProps) { const [lightboxIndex, setLightboxIndex] = useState(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 ( <>
{images.map((img, i) => ( ))}
{lightboxIndex !== null && createPortal( , 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 ( e.stopPropagation()}> {external.thumb && ( markActiveProxyFailed()} /> )}
{external.title}
{external.description}
{new URL(external.uri).hostname}
); } 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 ( e.stopPropagation()} > {external.title ); } return ( ); } 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 ( { e.preventDefault(); e.stopPropagation(); onOpenPost?.(record.uri, record.author.handle, record.author.did); }} >
{record.author.avatar ? ( ) : (
{displayName.charAt(0).toUpperCase()}
)} {displayName} @{record.author.handle} {isBlocked(record.author.did) && Blocked} {isMuted(record.author.did) && Muted}
); } 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(null); const hlsRef = useRef(null); const containerRef = useRef(null); const { autoplay: autoplaySetting } = useVideoAutoplay(); const { defaultVolume } = useDefaultVideoVolume(); const audioCtxRef = useRef(null); const gainNodeRef = useRef(null); const sourceNodeRef = useRef(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(null); const isDraggingSeekRef = useRef(false); const volumeBarRef = useRef(null); const isDraggingVolumeRef = useRef(false); const hideTimerRef = useRef | 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) => { e.stopPropagation(); setVolumeFromEvent(e); }, [setVolumeFromEvent]); const handleVolumeMouseDown = useCallback((e: React.MouseEvent) => { 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 (
{ if (isPlaying) setShowControls(false); }} >