diff --git a/bskyembed/package.json b/bskyembed/package.json index b74adb8351..f5cdd8cc37 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@atproto/api": "^0.15.25", + "hls.js": "^1.6.2", "preact": "^10.4.8" }, "devDependencies": { diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 105c785820..c865697c5f 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -11,7 +11,8 @@ import { AppBskyLabelerDefs, } from '@atproto/api' import {ComponentChildren, h} from 'preact' -import {useMemo} from 'preact/hooks' +import {lazy, Suspense} from 'preact/compat' +import {useMemo, useState} from 'preact/hooks' import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' import playIcon from '../../assets/play_filled_corner2_rounded.svg' @@ -23,6 +24,8 @@ import {getVerificationState} from '../util/verification-state' import {Link} from './link' import {VerificationCheck} from './verification-check' +const LazyVideoPlayer = lazy(() => import('./video-player')) + export function Embed({ content, labels, @@ -389,26 +392,66 @@ function GenericWithImageEmbed({ ) } -// just the thumbnail and a play button function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) { - let aspectRatio = 1 + const [activated, setActivated] = useState(content.presentation === 'gif') + let aspectRatio = 1 if (content.aspectRatio) { const {width, height} = content.aspectRatio aspectRatio = clamp(width / height, 1 / 1, 3 / 1) } + if (activated) { + return ( + + }> + + + ) + } + + return ( + setActivated(true)} + /> + ) +} + +function VideoEmbedFallback({ + content, + aspectRatio, + onPress, + loading, +}: { + content: AppBskyEmbedVideo.View + aspectRatio: number + onPress?: () => void + loading?: boolean +}) { return (
+ className="w-full overflow-hidden rounded-xl relative cursor-pointer" + style={{aspectRatio: `${aspectRatio} / 1`}} + onClick={onPress}> {content.alt}
- + {loading ? ( +
+ ) : ( + + )}
) diff --git a/bskyembed/src/components/video-player.tsx b/bskyembed/src/components/video-player.tsx new file mode 100644 index 0000000000..a9e20c7f92 --- /dev/null +++ b/bskyembed/src/components/video-player.tsx @@ -0,0 +1,1056 @@ +import {AppBskyEmbedVideo} from '@atproto/api' +import Hls from 'hls.js' +import {h} from 'preact' +import {useCallback, useEffect, useMemo, useRef, useState} from 'preact/hooks' + +// --- Icons (inline SVGs) --- + +function PlayIcon() { + return ( + + + + ) +} + +function PauseIcon() { + return ( + + + + ) +} + +function MuteIcon() { + return ( + + + + ) +} + +function UnmuteIcon() { + return ( + + + + ) +} + +function FullscreenIcon() { + return ( + + + + ) +} + +function ExitFullscreenIcon() { + return ( + + + + ) +} + +// --- Bandwidth estimate (module-level) --- + +let latestBandwidthEstimate: number | undefined + +// --- Volume state (module-level, persists across plays) --- + +let storedVolume = 1 + +// --- Utilities --- + +function formatTime(time: number) { + if (isNaN(time)) return '--' + time = Math.round(time) + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + return `${minutes}:${seconds}` +} + +function clamp(num: number, min: number, max: number) { + return Math.max(min, Math.min(num, max)) +} + +const isTouchDevice = typeof window !== 'undefined' && 'ontouchstart' in window + +const isFirefox = + typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) + +// --- useVideoElement hook --- + +function useVideoElement(ref: {current: HTMLVideoElement | null}) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [buffering, setBuffering] = useState(false) + const [error, setError] = useState(false) + const [canPlay, setCanPlay] = useState(false) + const playWhenReadyRef = useRef(false) + + useEffect(() => { + if (!ref.current) return + ref.current.volume = storedVolume + }, [ref]) + + useEffect(() => { + if (!ref.current) return + const el = ref.current + + let bufferingTimeout: ReturnType | undefined + + function round(num: number) { + return Math.round(num * 100) / 100 + } + + setCurrentTime(round(el.currentTime) || 0) + setDuration(round(el.duration) || 0) + setMuted(el.muted) + setPlaying(!el.paused) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + const handleDurationChange = () => { + if (!ref.current) return + setDuration(round(ref.current.duration) || 0) + } + const handlePlay = () => setPlaying(true) + const handlePause = () => setPlaying(false) + const handleVolumeChange = () => { + if (!ref.current) return + setMuted(ref.current.muted) + } + const handleError = () => setError(true) + const handleCanPlay = async () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setCanPlay(true) + if (!ref.current) return + if (playWhenReadyRef.current) { + try { + await ref.current.play() + } catch { + // ignore autoplay errors + } + playWhenReadyRef.current = false + } + } + const handleCanPlayThrough = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => setBuffering(true), 500) + } + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => setBuffering(true), 500) + } + const handleEnded = () => { + setPlaying(false) + setBuffering(false) + setError(false) + } + + const ac = new AbortController() + const opts = {signal: ac.signal} + el.addEventListener('timeupdate', handleTimeUpdate, opts) + el.addEventListener('durationchange', handleDurationChange, opts) + el.addEventListener('play', handlePlay, opts) + el.addEventListener('pause', handlePause, opts) + el.addEventListener('volumechange', handleVolumeChange, opts) + el.addEventListener('error', handleError, opts) + el.addEventListener('canplay', handleCanPlay, opts) + el.addEventListener('canplaythrough', handleCanPlayThrough, opts) + el.addEventListener('waiting', handleWaiting, opts) + el.addEventListener('playing', handlePlaying, opts) + el.addEventListener('stalled', handleStalled, opts) + el.addEventListener('ended', handleEnded, opts) + + return () => { + ac.abort() + clearTimeout(bufferingTimeout) + } + }, [ref]) + + const play = useCallback(() => { + if (!ref.current) return + if (ref.current.ended) ref.current.currentTime = 0 + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + playWhenReadyRef.current = true + } else { + const promise = ref.current.play() + if (promise !== undefined) { + promise.catch(() => {}) + } + } + }, [ref]) + + const pause = useCallback(() => { + if (!ref.current) return + ref.current.pause() + playWhenReadyRef.current = false + }, [ref]) + + const togglePlayPause = useCallback(() => { + if (!ref.current) return + if (ref.current.paused) { + play() + } else { + pause() + } + }, [ref, play, pause]) + + const changeMuted = useCallback( + (newMuted: boolean | ((prev: boolean) => boolean)) => { + if (!ref.current) return + const value = + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted + ref.current.muted = value + }, + [ref], + ) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + changeMuted, + buffering, + error, + canPlay, + } +} + +// --- Scrubber --- + +function Scrubber({ + duration, + currentTime, + onSeek, + onSeekEnd, + onSeekStart, + seekLeft, + seekRight, + togglePlayPause, +}: { + duration: number + currentTime: number + onSeek: (time: number) => void + onSeekEnd: () => void + onSeekStart: () => void + seekLeft: () => void + seekRight: () => void + togglePlayPause: () => void +}) { + const [scrubberActive, setScrubberActive] = useState(false) + const [hovered, setHovered] = useState(false) + const [focused, setFocused] = useState(false) + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: PointerEvent) => { + if (!barRef.current) return + const {left, width} = barRef.current.getBoundingClientRect() + const x = evt.clientX + const percent = clamp((x - left) / width, 0, 1) * duration + onSeek(percent) + setSeekPosition(percent) + }, + [duration, onSeek], + ) + + const onPointerDown = useCallback( + (evt: PointerEvent) => { + const target = evt.target + if (target instanceof Element) { + evt.preventDefault() + target.setPointerCapture(evt.pointerId) + isSeekingRef.current = true + seek(evt) + setScrubberActive(true) + onSeekStart() + } + }, + [seek, onSeekStart], + ) + + const onPointerMove = useCallback( + (evt: PointerEvent) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: PointerEvent) => { + const target = evt.target + if (isSeekingRef.current && target instanceof Element) { + evt.preventDefault() + target.releasePointerCapture(evt.pointerId) + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + } + }, + [onSeekEnd], + ) + + useEffect(() => { + if (isFirefox && scrubberActive) { + document.body.classList.add('force-no-clicks') + return () => { + document.body.classList.remove('force-no-clicks') + } + } + }, [scrubberActive]) + + useEffect(() => { + if (!circleRef.current) return + if (!focused) return + const el = circleRef.current + const handler = (evt: KeyboardEvent) => { + if (evt.key === ' ') { + evt.preventDefault() + togglePlayPause() + } else if (evt.key === 'ArrowLeft') { + evt.preventDefault() + seekLeft() + } else if (evt.key === 'ArrowRight') { + evt.preventDefault() + seekRight() + } + } + el.addEventListener('keydown', handler) + return () => el.removeEventListener('keydown', handler) + }, [focused, seekLeft, seekRight, togglePlayPause]) + + const progress = scrubberActive ? seekPosition : currentTime + const progressPercent = duration > 0 ? (progress / duration) * 100 : 0 + + if (duration < 3) return null + + return ( +
setHovered(true)} + onPointerLeave={() => setHovered(false)}> +
+
+ {duration > 0 && ( +
+ )} +
+
setFocused(true)} + onBlur={() => setFocused(false)} + style={{ + position: 'absolute', + height: 16, + width: 16, + left: `calc(${progressPercent}% - 8px)`, + borderRadius: 8, + pointerEvents: 'none', + }}> +
+
+
+
+ ) +} + +// --- Volume Control --- + +function VolumeControl({ + muted, + changeMuted, +}: { + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void +}) { + const [hovered, setHovered] = useState(false) + + const sliderVolume = muted + ? 0 + : Math.round(Math.pow(storedVolume, 1 / 4) * 100) + + const onVolumeChange = useCallback( + (evt: Event) => { + const target = evt.target as HTMLInputElement + const vol = Math.pow(Number(target.value) / 100, 4) + storedVolume = vol + changeMuted(vol === 0) + }, + [changeMuted], + ) + + const onPressMute = useCallback(() => { + if (storedVolume === 0) { + storedVolume = 1 + changeMuted(false) + } else { + changeMuted(prev => !prev) + } + }, [changeMuted]) + + return ( +
setHovered(true)} + onPointerLeave={() => setHovered(false)}> + {hovered && !isTouchDevice && ( +
+
+ +
+
+ )} + : } + onPress={onPressMute} + /> +
+ ) +} + +// --- Control Button --- + +function ControlButton({ + label, + icon, + onPress, +}: { + label: string + icon: preact.ComponentChildren + onPress: () => void +}) { + const [hovered, setHovered] = useState(false) + + return ( + + ) +} + +// --- Controls --- + +function Controls({ + videoRef, + hlsRef, + playing, + muted, + changeMuted, + currentTime, + duration, + buffering, + error, + play, + pause, + togglePlayPause, + hlsLoading, + isGif, + containerRef, +}: { + videoRef: {current: HTMLVideoElement | null} + hlsRef: {current: Hls | null} + playing: boolean + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void + currentTime: number + duration: number + buffering: boolean + error: boolean + play: () => void + pause: () => void + togglePlayPause: () => void + hlsLoading: boolean + isGif: boolean + containerRef: {current: HTMLDivElement | null} +}) { + const [hovered, setHovered] = useState(false) + const [focused, setFocused] = useState(false) + const [showCursor, setShowCursor] = useState(true) + const [isFullscreen, setIsFullscreen] = useState(false) + const cursorTimeoutRef = useRef>(undefined) + const timeoutRef = useRef>(undefined) + const showSpinner = hlsLoading || buffering + + // Fullscreen handling + useEffect(() => { + const handler = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', handler) + return () => document.removeEventListener('fullscreenchange', handler) + }, []) + + const toggleFullscreen = useCallback(() => { + if (!containerRef.current) return + if (document.fullscreenElement) { + document.exitFullscreen() + } else { + containerRef.current.requestFullscreen() + } + }, [containerRef]) + + // Buffer management + useEffect(() => { + if (!hlsRef.current) return + if (focused) { + hlsRef.current.config.maxMaxBufferLength = 30 + } else { + hlsRef.current.config.maxMaxBufferLength = 10 + } + }, [hlsRef, focused]) + + const showControls = (!focused && !playing) || hovered + + const onPointerMoveEmptySpace = useCallback(() => { + setShowCursor(true) + if (cursorTimeoutRef.current) clearTimeout(cursorTimeoutRef.current) + cursorTimeoutRef.current = setTimeout(() => { + setShowCursor(false) + setHovered(false) + }, 2000) + }, []) + + const onPointerLeaveEmptySpace = useCallback(() => { + setShowCursor(false) + if (cursorTimeoutRef.current) clearTimeout(cursorTimeoutRef.current) + }, []) + + const onPressEmptySpace = useCallback(() => { + if (!focused) { + setFocused(true) + play() + } else { + togglePlayPause() + } + }, [focused, play, togglePlayPause]) + + const onHoverWithTimeout = useCallback(() => { + setHovered(true) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + }, []) + + const onEndHoverWithTimeout = useCallback((evt: PointerEvent) => { + if (evt.pointerType !== 'mouse') { + timeoutRef.current = setTimeout(() => setHovered(false), 3000) + } else { + setHovered(false) + } + }, []) + + const onPointerDown = useCallback( + (evt: PointerEvent) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + if (timeoutRef.current) clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + const onSeekStart = useCallback(() => { + setFocused(true) + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) play() + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + const dur = videoRef.current.duration || 0 + onSeek(clamp(videoRef.current.currentTime - 5, 0, dur)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + const dur = videoRef.current.duration || 0 + onSeek(clamp(videoRef.current.currentTime + 5, 0, dur)) + }, [onSeek, videoRef]) + + if (isGif) { + return ( +
+ {!playing && ( +
+ +
+ )} + {showSpinner &&
} +
+ GIF +
+
+ ) + } + + return ( +
evt.stopPropagation()} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown}> + {/* Empty space - click to play/pause */} +
+ {/* Time indicator when controls are hidden */} + {!showControls && !focused && duration > 0 && ( +
+ {formatTime(Math.floor(duration - currentTime))} +
+ )} + {/* Controls bar */} +
+ +
+ : } + onPress={() => { + setFocused(true) + togglePlayPause() + }} + /> +
+ {Math.round(duration) > 0 && ( + + {formatTime(currentTime)} / {formatTime(duration)} + + )} + + : } + onPress={() => { + setFocused(true) + toggleFullscreen() + }} + /> +
+
+ {/* Spinner / Error overlay */} + {(showSpinner || error) && ( +
+ {showSpinner &&
} + {error && ( + + An error occurred + + )} +
+ )} +
+ ) +} + +// --- Main VideoPlayer component (lazy-loaded) --- + +export default function VideoPlayer({ + content, +}: { + content: AppBskyEmbedVideo.View +}) { + const containerRef = useRef(null) + const videoRef = useRef(null) + const hlsRef = useRef(null) + const [hlsLoading, setHlsLoading] = useState(true) + const [hlsError, setHlsError] = useState(null) + + const isGif = content.presentation === 'gif' + + let aspectRatio = 1 + if (content.aspectRatio) { + const {width, height} = content.aspectRatio + aspectRatio = clamp(width / height, 1 / 1, 3 / 1) + } + + // Setup HLS + useEffect(() => { + if (!videoRef.current) return + + if (!Hls.isSupported()) { + // Try native HLS (Safari) + if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) { + videoRef.current.src = content.playlist + setHlsLoading(false) + return + } + setHlsError('HLS is not supported in this browser') + setHlsLoading(false) + return + } + + const hls = new Hls({ + maxMaxBufferLength: 10, + startLevel: + latestBandwidthEstimate === undefined + ? -1 + : Hls.DefaultConfig.startLevel, + }) + hlsRef.current = hls + + if (latestBandwidthEstimate !== undefined) { + hls.bandwidthEstimate = latestBandwidthEstimate + } + + hls.attachMedia(videoRef.current) + hls.loadSource(content.playlist) + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setHlsLoading(false) + }) + + hls.on(Hls.Events.FRAG_LOADED, () => { + if (!isNaN(hls.bandwidthEstimate)) { + latestBandwidthEstimate = hls.bandwidthEstimate + } + }) + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + if ( + data.details === 'manifestLoadError' && + data.response?.code === 404 + ) { + setHlsError('Video not found') + } else { + setHlsError('An error occurred loading the video') + } + } + }) + + return () => { + hlsRef.current = null + hls.detachMedia() + hls.destroy() + } + }, [content.playlist]) + + const videoEl = useVideoElement(videoRef) + + // Auto-play for GIFs + useEffect(() => { + if (isGif && !hlsLoading && videoEl.canPlay) { + videoEl.play() + } + }, [isGif, hlsLoading, videoEl.canPlay, videoEl.play]) + + // Auto-play for videos (user clicked to load the player) + useEffect(() => { + if (!isGif && !hlsLoading && videoEl.canPlay) { + videoEl.play() + videoEl.changeMuted(false) + } + }, [isGif, hlsLoading, videoEl.canPlay, videoEl.play, videoEl.changeMuted]) + + const figId = useMemo( + () => `video-fig-${Math.random().toString(36).slice(2)}`, + [], + ) + + if (hlsError) { + return ( +
+ {hlsError} +
+ ) + } + + return ( +
+
+
+ +
+ ) +} diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css index 91ea9d7ee6..f02953d392 100644 --- a/bskyembed/src/index.css +++ b/bskyembed/src/index.css @@ -10,6 +10,35 @@ color-scheme: light dark; } +/* Video player spinner */ +@keyframes video-spin { + to { + transform: rotate(360deg); + } +} + +.video-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: video-spin 0.8s linear infinite; +} + +/* Firefox scrubber workaround */ +.force-no-clicks * { + pointer-events: none !important; +} + +/* Volume slider vertical orientation */ +input[type='range'][orient='vertical'] { + writing-mode: vertical-lr; + direction: rtl; + appearance: slider-vertical; + -webkit-appearance: slider-vertical; +} + select { background-image: url("data:image/svg+xml,"); background-repeat: no-repeat;