a7cd13228b
Adds a new autoplayVideos config field and a toggle in Settings > Content > Video Playback. When enabled, videos auto-play muted in feeds, collage thumbnails, profile sidebar tiles, the Vines feed, and the VideoPlayer component. The preference syncs across devices via encrypted settings. usePlayerControls now listens for the volumechange event to keep the volume UI in sync when the video is programmatically muted for autoplay.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
import type Hls from 'hls.js';
|
|
import { Play, Pause, Volume1, Volume2, VolumeX, Expand } from 'lucide-react';
|
|
import { Blurhash } from 'react-blurhash';
|
|
import { cn } from '@/lib/utils';
|
|
import { isValidBlurhash } from '@/lib/blurhash';
|
|
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
|
import { usePlayerControls } from '@/hooks/usePlayerControls';
|
|
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { formatTime } from '@/lib/formatTime';
|
|
|
|
interface VideoPlayerProps {
|
|
src: string;
|
|
poster?: string;
|
|
className?: string;
|
|
/** NIP-94 `dim` tag value, e.g. "1280x720". Sets the aspect ratio before metadata loads. */
|
|
dim?: string;
|
|
/** NIP-94 `blurhash` tag value. Shown as a placeholder before the video poster/frame loads. */
|
|
blurhash?: string;
|
|
/** Video title shown in OS media controls. */
|
|
title?: string;
|
|
/** Artist / author name shown in OS media controls. */
|
|
artist?: string;
|
|
/** When true, the video auto-plays muted without requiring a click. */
|
|
autoPlay?: boolean;
|
|
}
|
|
|
|
/** Parses a NIP-94 `dim` string like "1280x720" into `{ width, height }`. */
|
|
function parseDim(dim: string | undefined): { width: number; height: number } | undefined {
|
|
if (!dim) return undefined;
|
|
const [w, h] = dim.split('x').map(Number);
|
|
if (!w || !h || isNaN(w) || isNaN(h)) return undefined;
|
|
return { width: w, height: h };
|
|
}
|
|
|
|
|
|
/** Attaches hls.js to a video element for HLS streams on non-Safari browsers. */
|
|
function useHls(videoRef: React.RefObject<HTMLVideoElement | null>, src: string) {
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
|
|
const isHls = /\.m3u8(\?|$)/i.test(src);
|
|
|
|
const attach = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !isHls) return;
|
|
|
|
// Safari supports HLS natively
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = src;
|
|
return;
|
|
}
|
|
|
|
// Dynamically import hls.js to keep it out of the main bundle
|
|
import('hls.js').then(({ default: HlsLib }) => {
|
|
// Guard against stale closure (component unmounted or src changed)
|
|
if (videoRef.current !== video) return;
|
|
if (!HlsLib.isSupported()) return;
|
|
|
|
const hls = new HlsLib({ startLevel: -1, autoStartLoad: true });
|
|
hlsRef.current = hls;
|
|
hls.loadSource(src);
|
|
hls.attachMedia(video);
|
|
});
|
|
}, [videoRef, src, isHls]);
|
|
|
|
useEffect(() => {
|
|
attach();
|
|
return () => {
|
|
hlsRef.current?.destroy();
|
|
hlsRef.current = null;
|
|
};
|
|
}, [attach]);
|
|
|
|
return { isHls };
|
|
}
|
|
|
|
export function VideoPlayer({ src: originalSrc, poster, className, dim, blurhash, title, artist, autoPlay }: VideoPlayerProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const { src, onError: onBlossomError } = useBlossomFallback(originalSrc);
|
|
const { isHls } = useHls(videoRef, src);
|
|
const { config } = useAppContext();
|
|
const shouldAutoPlay = autoPlay ?? config.autoplayVideos;
|
|
|
|
const generatedPoster = useVideoThumbnail(src, poster);
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [hasStarted, setHasStarted] = useState(false);
|
|
// True once the video has enough data to display a frame (or has a poster/generated thumbnail)
|
|
const [videoReady, setVideoReady] = useState(!!poster);
|
|
|
|
const dimensions = parseDim(dim);
|
|
const aspectRatio = dimensions ? `${dimensions.width} / ${dimensions.height}` : undefined;
|
|
|
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
|
|
const { showControls, revealControls, scheduleHide, isMuted, volume, toggleMute, handleVolumeChange } = usePlayerControls({
|
|
mediaRef: videoRef,
|
|
containerRef,
|
|
isPlaying,
|
|
});
|
|
|
|
// Autoplay: start muted when enabled via prop or global setting.
|
|
// Uses onLoadedData to ensure the element is ready before calling play().
|
|
const autoplayAttempted = useRef(false);
|
|
useEffect(() => {
|
|
if (!shouldAutoPlay) return;
|
|
autoplayAttempted.current = false;
|
|
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const attemptPlay = () => {
|
|
if (autoplayAttempted.current) return;
|
|
autoplayAttempted.current = true;
|
|
video.muted = true;
|
|
video.play().catch(() => {
|
|
// Autoplay blocked by browser — leave paused, user can click to play
|
|
});
|
|
};
|
|
|
|
// If the element already has data, play immediately; otherwise wait.
|
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
attemptPlay();
|
|
} else {
|
|
video.addEventListener('loadeddata', attemptPlay, { once: true });
|
|
return () => video.removeEventListener('loadeddata', attemptPlay);
|
|
}
|
|
}, [shouldAutoPlay]);
|
|
|
|
// Media Session API — registers OS lock-screen / notification controls
|
|
useEffect(() => {
|
|
if (!('mediaSession' in navigator)) return;
|
|
if (!hasStarted) return;
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const artwork: MediaImage[] = (generatedPoster || poster)
|
|
? [{ src: (generatedPoster || poster)!, sizes: '512x512', type: 'image/jpeg' }]
|
|
: [];
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: title || 'Video',
|
|
artist: artist || '',
|
|
artwork,
|
|
});
|
|
|
|
navigator.mediaSession.setActionHandler('play', () => video.play().catch(() => {}));
|
|
navigator.mediaSession.setActionHandler('pause', () => video.pause());
|
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
if (details.seekTime != null) video.currentTime = details.seekTime;
|
|
});
|
|
navigator.mediaSession.setActionHandler('previoustrack', null);
|
|
navigator.mediaSession.setActionHandler('nexttrack', null);
|
|
|
|
return () => {
|
|
if (!('mediaSession' in navigator)) return;
|
|
navigator.mediaSession.metadata = null;
|
|
navigator.mediaSession.setActionHandler('play', null);
|
|
navigator.mediaSession.setActionHandler('pause', null);
|
|
navigator.mediaSession.setActionHandler('seekto', null);
|
|
};
|
|
}, [hasStarted, title, artist, poster, generatedPoster]);
|
|
|
|
// Keep OS playback state in sync
|
|
useEffect(() => {
|
|
if (!('mediaSession' in navigator) || !hasStarted) return;
|
|
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
|
}, [isPlaying, hasStarted]);
|
|
|
|
// Keep OS position/scrubber in sync
|
|
useEffect(() => {
|
|
if (!('mediaSession' in navigator) || !hasStarted || duration <= 0) return;
|
|
try {
|
|
navigator.mediaSession.setPositionState({
|
|
duration,
|
|
playbackRate: videoRef.current?.playbackRate ?? 1,
|
|
position: Math.min(currentTime, duration),
|
|
});
|
|
} catch { /* setPositionState may throw on some browsers */ }
|
|
}, [currentTime, duration, hasStarted]);
|
|
|
|
const togglePlay = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
if (video.paused) {
|
|
video.play();
|
|
} else {
|
|
video.pause();
|
|
}
|
|
};
|
|
|
|
const handleFullscreen = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
if (video.requestFullscreen) {
|
|
video.requestFullscreen();
|
|
}
|
|
};
|
|
|
|
const handleSeek = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const video = videoRef.current;
|
|
const bar = progressRef.current;
|
|
if (!video || !bar || !duration) return;
|
|
const rect = bar.getBoundingClientRect();
|
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
video.currentTime = ratio * duration;
|
|
};
|
|
|
|
const handleVideoClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!hasStarted) {
|
|
const video = videoRef.current;
|
|
if (video) video.play();
|
|
return;
|
|
}
|
|
togglePlay(e);
|
|
revealControls();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
'relative mt-3 rounded-2xl overflow-hidden border border-border bg-black group',
|
|
className,
|
|
)}
|
|
style={aspectRatio ? { aspectRatio } : undefined}
|
|
onMouseMove={revealControls}
|
|
onMouseLeave={() => { if (isPlaying) scheduleHide(); }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Blurhash placeholder — shown until the video has a displayable frame */}
|
|
{isValidBlurhash(blurhash) && !videoReady && !generatedPoster && (
|
|
<Blurhash
|
|
hash={blurhash}
|
|
width="100%"
|
|
height="100%"
|
|
resolutionX={32}
|
|
resolutionY={32}
|
|
punch={1}
|
|
style={{ position: 'absolute', inset: 0 }}
|
|
/>
|
|
)}
|
|
|
|
<video
|
|
ref={videoRef}
|
|
src={isHls ? undefined : src}
|
|
poster={generatedPoster}
|
|
className={cn(
|
|
'w-full cursor-pointer',
|
|
// When dim is known the container already has the correct aspect ratio,
|
|
// so the video just needs to fill it (absolute inset-0). Without dim we
|
|
// fall back to the original constrained height with object-cover so the
|
|
// player doesn't grow to an unmanageable size.
|
|
aspectRatio
|
|
? 'absolute inset-0 h-full object-cover'
|
|
: 'max-h-[70vh] object-cover',
|
|
)}
|
|
playsInline
|
|
preload="metadata"
|
|
{...({ 'webkit-playsinline': 'true' } as React.HTMLAttributes<HTMLVideoElement>)}
|
|
{...({ 'x-webkit-airplay': 'allow' } as React.HTMLAttributes<HTMLVideoElement>)}
|
|
onClick={handleVideoClick}
|
|
onPlay={() => { setIsPlaying(true); setHasStarted(true); }}
|
|
onPause={() => setIsPlaying(false)}
|
|
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime ?? 0)}
|
|
onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
|
|
onDurationChange={() => setDuration(videoRef.current?.duration ?? 0)}
|
|
onLoadedData={() => setVideoReady(true)}
|
|
onError={onBlossomError}
|
|
/>
|
|
|
|
{/* Big centered play button before first play */}
|
|
{!hasStarted && (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center bg-black/30 cursor-pointer"
|
|
onClick={handleVideoClick}
|
|
>
|
|
<div className="size-16 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
|
|
<Play className="size-8 text-white ml-1" fill="white" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom control bar */}
|
|
{hasStarted && (
|
|
<div
|
|
className={cn(
|
|
'absolute bottom-0 left-0 right-0 transition-opacity duration-200',
|
|
'bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-8 pb-2 px-3',
|
|
showControls ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
|
)}
|
|
>
|
|
{/* Progress bar */}
|
|
<div
|
|
ref={progressRef}
|
|
className="w-full h-1 bg-white/30 rounded-full cursor-pointer mb-2 group/progress"
|
|
onClick={handleSeek}
|
|
>
|
|
<div
|
|
className="h-full bg-primary rounded-full relative"
|
|
style={{ width: `${progress}%` }}
|
|
>
|
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 size-3 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls row */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Play/Pause */}
|
|
<button
|
|
onClick={togglePlay}
|
|
className="text-white hover:text-white/80 transition-colors"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Pause className="size-5" fill="white" /> : <Play className="size-5 ml-0.5" fill="white" />}
|
|
</button>
|
|
|
|
{/* Volume: icon toggles mute, slider sets level */}
|
|
<div className="flex items-center gap-1.5 group/vol">
|
|
<button
|
|
onClick={toggleMute}
|
|
className="text-white hover:text-white/80 transition-colors shrink-0"
|
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
>
|
|
{isMuted || volume === 0
|
|
? <VolumeX className="size-5" />
|
|
: volume < 0.5
|
|
? <Volume1 className="size-5" />
|
|
: <Volume2 className="size-5" />}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.02}
|
|
value={isMuted ? 0 : volume}
|
|
onChange={handleVolumeChange}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Volume"
|
|
className={cn(
|
|
'w-0 opacity-0 group-hover/vol:w-16 group-hover/vol:opacity-100 group-focus-within/vol:w-16 group-focus-within/vol:opacity-100',
|
|
'transition-all duration-200 cursor-pointer accent-white h-1',
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Time */}
|
|
<span className="text-white text-xs tabular-nums min-w-0">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Fullscreen */}
|
|
<button
|
|
onClick={handleFullscreen}
|
|
className="text-white hover:text-white/80 transition-colors"
|
|
aria-label="Fullscreen"
|
|
>
|
|
<Expand className="size-[18px]" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|