0c29506402
- Extract utility functions from component files into dedicated modules to fix react-refresh/only-export-components warnings: - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts - parseProfileBadges -> src/lib/parseProfileBadges.ts - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts - usePortalContainer -> src/hooks/usePortalContainer.ts - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts - BlobbiActions context def -> BlobbiActionsContextDef.ts - Remove export from internal-only functions (useEventComments, parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries) - Fix react-hooks/exhaustive-deps warnings by adding missing dependencies to useEffect/useCallback/useMemo hooks across 14 files - Fix logical expression dependency warnings by wrapping conditional values (tasks, pubkeys, authorPubkeys) in useMemo - Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS, DEFAULT_TAB_LABELS) out of ProfilePage component body - Reorder usePushNotifications hooks so syncPreferences is defined before enable to fix block-scoped variable error
342 lines
12 KiB
TypeScript
342 lines
12 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 { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
|
import { usePlayerControls } from '@/hooks/usePlayerControls';
|
|
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
|
|
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;
|
|
}
|
|
|
|
/** 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 }: 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 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,
|
|
});
|
|
|
|
// 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 */}
|
|
{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>
|
|
);
|
|
}
|