chore(player): remove HLS media player components

This commit is contained in:
2025-10-28 05:35:33 +00:00
parent adb4e4c1b4
commit 7849af8484
6 changed files with 0 additions and 683 deletions

View File

@@ -1,229 +0,0 @@
import Hls from "hls.js";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useKeyboardControls } from "@/hooks/useKeyboardControls";
interface HLSPlayerProps {
playlistUrl: string;
sessionId: string;
onSeek?: (time: number) => Promise<void>;
onTimeUpdate?: (time: number) => void;
onDurationChange?: (duration: number) => void;
onPlayStateChange?: (isPlaying: boolean) => void;
onVolumeChange?: (volume: number) => void;
}
export interface HLSPlayerRef {
videoElement: HTMLVideoElement | null;
}
const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(function HLSPlayer(
{
playlistUrl,
onSeek,
onTimeUpdate,
onDurationChange,
onPlayStateChange,
onVolumeChange,
},
ref,
) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [_isReady, setIsReady] = useState(false);
const lastSeekTimeRef = useRef<number>(0);
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Expose video element to parent
useImperativeHandle(ref, () => ({
videoElement: videoRef.current,
}));
// Keyboard controls
useKeyboardControls(videoRef, {
onSeek: (delta) => {
if (videoRef.current) {
const newTime = Math.max(
0,
Math.min(
videoRef.current.duration,
videoRef.current.currentTime + delta,
),
);
videoRef.current.currentTime = newTime;
}
},
onVolumeChange: (delta) => {
if (videoRef.current) {
const newVolume = Math.max(
0,
Math.min(1, videoRef.current.volume + delta),
);
videoRef.current.volume = newVolume;
if (onVolumeChange) {
onVolumeChange(newVolume);
}
}
},
});
// Initialize HLS
useEffect(() => {
if (!videoRef.current) return;
// Check HLS support
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferSize: 60 * 1000 * 1000, // 60MB
maxBufferHole: 0.5,
});
hls.loadSource(playlistUrl);
hls.attachMedia(videoRef.current);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setIsReady(true);
// Auto-play when ready
videoRef.current?.play().catch((err) => {
console.warn("Autoplay failed:", err);
});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
console.error("HLS Error:", data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
toast.error("Network error occurred, attempting to recover...");
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
toast.error("Media error occurred, attempting to recover...");
hls.recoverMediaError();
break;
default:
toast.error("Fatal error occurred, cannot recover");
hls.destroy();
break;
}
}
});
hlsRef.current = hls;
return () => {
hls.destroy();
};
}
// Native HLS support (Safari)
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl") !== "") {
videoRef.current.src = playlistUrl;
setIsReady(true);
videoRef.current.addEventListener("loadedmetadata", () => {
videoRef.current?.play().catch((err) => {
console.warn("Autoplay failed:", err);
});
});
} else {
toast.error("HLS is not supported in this browser");
}
}, [playlistUrl]);
// Handle seeking with debounce for server-side seek
const handleSeeking = () => {
if (!videoRef.current || !onSeek) return;
const currentTime = videoRef.current.currentTime;
const timeDiff = Math.abs(currentTime - lastSeekTimeRef.current);
// Only trigger server-side seek if seeking more than 5 seconds
if (timeDiff > 5) {
// Clear existing timeout
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
}
// Debounce seek to avoid rapid requests
seekTimeoutRef.current = setTimeout(async () => {
try {
await onSeek(currentTime);
lastSeekTimeRef.current = currentTime;
// Reload playlist after server-side seek
if (hlsRef.current) {
hlsRef.current.loadSource(`${playlistUrl}?t=${Date.now()}`);
} else if (videoRef.current) {
videoRef.current.src = `${playlistUrl}?t=${Date.now()}`;
}
} catch (error) {
console.error("Seek failed:", error);
}
}, 500);
}
};
// Event handlers
const handleTimeUpdate = () => {
if (videoRef.current && onTimeUpdate) {
onTimeUpdate(videoRef.current.currentTime);
}
};
const handleDurationChange = () => {
if (videoRef.current && onDurationChange) {
onDurationChange(videoRef.current.duration);
}
};
const handlePlay = () => {
if (onPlayStateChange) {
onPlayStateChange(true);
}
};
const handlePause = () => {
if (onPlayStateChange) {
onPlayStateChange(false);
}
};
const handleVolumeChange = () => {
if (videoRef.current && onVolumeChange) {
onVolumeChange(videoRef.current.volume);
}
};
return (
<video
ref={videoRef}
className="h-full w-full bg-black object-contain"
onTimeUpdate={handleTimeUpdate}
onDurationChange={handleDurationChange}
onSeeking={handleSeeking}
onPlay={handlePlay}
onPause={handlePause}
onVolumeChange={handleVolumeChange}
playsInline
>
{/* Empty track for a11y compliance - subtitles are burned into video */}
<track kind="captions" />
</video>
);
});
export default HLSPlayer;

View File

@@ -1,316 +0,0 @@
import { useNavigate } from "@tanstack/react-router";
import {
Maximize,
Minimize,
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { formatTime } from "@/utils/codec-detection";
interface PlayerControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
titleData?: {
title: string;
type?: string;
};
episodeInfo?: {
seasonNumber?: number;
episodeNumber?: number;
};
}
export function PlayerControls({
videoRef,
titleData,
episodeInfo,
}: PlayerControlsProps) {
const navigate = useNavigate();
const [isVisible, setIsVisible] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Auto-hide controls after 3 seconds of inactivity
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const resetTimeout = () => {
setIsVisible(true);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (isPlaying) {
setIsVisible(false);
}
}, 3000);
};
const handleMouseMove = () => resetTimeout();
const handleMouseLeave = () => {
if (isPlaying) {
setIsVisible(false);
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseleave", handleMouseLeave);
resetTimeout();
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseleave", handleMouseLeave);
};
}, [isPlaying]);
// Sync state with video element
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const updateTime = () => setCurrentTime(video.currentTime);
const updateDuration = () => setDuration(video.duration);
const updatePlayState = () => setIsPlaying(!video.paused);
const updateVolume = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", updateTime);
video.addEventListener("durationchange", updateDuration);
video.addEventListener("play", updatePlayState);
video.addEventListener("pause", updatePlayState);
video.addEventListener("volumechange", updateVolume);
// Initialize
updateDuration();
updatePlayState();
updateVolume();
return () => {
video.removeEventListener("timeupdate", updateTime);
video.removeEventListener("durationchange", updateDuration);
video.removeEventListener("play", updatePlayState);
video.removeEventListener("pause", updatePlayState);
video.removeEventListener("volumechange", updateVolume);
};
}, [videoRef]);
// Fullscreen change listener
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
};
const handleSeek = (value: number[]) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = value[0];
};
const handleSkipBack = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, video.currentTime - 10);
};
const handleSkipForward = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.min(video.duration, video.currentTime + 10);
};
const handleVolumeChange = (value: number[]) => {
const video = videoRef.current;
if (!video) return;
video.volume = value[0];
if (value[0] > 0 && video.muted) {
video.muted = false;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleClose = () => {
navigate({ to: "/home" });
};
return (
<section
className={cn(
"absolute inset-0 transition-opacity duration-300",
isVisible ? "opacity-100" : "pointer-events-none opacity-0",
)}
onMouseEnter={() => setIsVisible(true)}
aria-label="Video player controls"
>
{/* Top bar - Title, Episode info, Close button */}
<div className="absolute top-0 right-0 left-0 bg-gradient-to-b from-black/80 via-black/40 to-transparent p-4">
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-lg text-white sm:text-xl">
{titleData?.title || "Loading..."}
</h1>
{episodeInfo && (
<p className="text-muted-foreground text-sm">
S{episodeInfo.seasonNumber} E{episodeInfo.episodeNumber}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="text-white hover:bg-white/20"
>
<X className="h-6 w-6" />
</Button>
</div>
</div>
{/* Center - Play/Pause overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<Button
variant="ghost"
size="icon"
className="h-20 w-20 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={togglePlay}
>
{isPlaying ? (
<Pause className="h-10 w-10" />
) : (
<Play className="ml-1 h-10 w-10" />
)}
</Button>
</div>
{/* Bottom controls */}
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4">
{/* Progress bar */}
<Slider
value={[currentTime]}
max={duration || 100}
step={0.1}
onValueChange={handleSeek}
className="mb-4"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={togglePlay}
className="text-white hover:bg-white/20"
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleSkipBack}
className="text-white hover:bg-white/20"
>
<SkipBack className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleSkipForward}
className="text-white hover:bg-white/20"
>
<SkipForward className="h-5 w-5" />
</Button>
{/* Volume control */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={toggleMute}
className="text-white hover:bg-white/20"
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<Slider
value={[isMuted ? 0 : volume]}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="hidden w-20 sm:block"
/>
</div>
<span className="hidden text-sm text-white sm:inline">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
className="text-white hover:bg-white/20"
>
{isFullscreen ? (
<Minimize className="h-5 w-5" />
) : (
<Maximize className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -1,33 +0,0 @@
import { useNavigate } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
interface PlayerErrorStateProps {
error: Error;
}
export function PlayerErrorState({ error }: PlayerErrorStateProps) {
const navigate = useNavigate();
return (
<div className="flex h-screen w-full items-center justify-center bg-black p-4">
<div className="w-full max-w-md space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Playback Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/home" })}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import { Loader2 } from "lucide-react";
export function PlayerLoadingState() {
return (
<div className="flex h-screen w-full items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">Preparing playback...</p>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
import { lazy, Suspense, useEffect, useMemo, useRef } from "react";
import { usePlaybackSession } from "@/hooks/usePlaybackSession";
import { detectCodecSupport } from "@/utils/codec-detection";
import type { HLSPlayerRef } from "./HLSPlayer";
import { PlayerControls } from "./PlayerControls";
import { PlayerErrorState } from "./PlayerErrorState";
import { PlayerLoadingState } from "./PlayerLoadingState";
// Lazy load HLS player for code splitting
const HLSPlayer = lazy(() => import("./HLSPlayer"));
interface VideoPlayerProps {
titleId?: string;
episodeId?: string;
titleData?: {
title: string;
type?: string;
};
episodeInfo?: {
seasonNumber?: number;
episodeNumber?: number;
};
selectedTracks?: {
audio?: number;
subtitle?: number;
};
}
export function VideoPlayer({
titleId,
episodeId,
titleData,
episodeInfo,
selectedTracks,
}: VideoPlayerProps) {
const hlsPlayerRef = useRef<HLSPlayerRef>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// Get video element from HLS player
useEffect(() => {
if (hlsPlayerRef.current?.videoElement) {
videoRef.current = hlsPlayerRef.current.videoElement;
}
}, []);
// Detect browser codec support
const supportedCodecs = useMemo(() => detectCodecSupport(), []);
// Create playback session
const session = usePlaybackSession({
titleId,
episodeId,
supportedCodecs,
selectedTracks,
maxResolution: "1080p",
});
if (session.isLoading) {
return <PlayerLoadingState />;
}
if (session.error) {
return <PlayerErrorState error={session.error} />;
}
if (!session.data) {
return <PlayerLoadingState />;
}
return (
<div className="relative h-screen w-full bg-black">
{/* HLS Player - Lazy loaded */}
<Suspense fallback={<PlayerLoadingState />}>
<HLSPlayer
ref={hlsPlayerRef}
playlistUrl={session.data.playlistUrl}
onSeek={session.seek}
/>
</Suspense>
{/* Player Controls Overlay */}
<PlayerControls
videoRef={videoRef}
titleData={titleData}
episodeInfo={episodeInfo}
/>
</div>
);
}

View File

@@ -1,4 +0,0 @@
export { VideoPlayer } from "./VideoPlayer";
export { PlayerControls } from "./PlayerControls";
export { PlayerLoadingState } from "./PlayerLoadingState";
export { PlayerErrorState } from "./PlayerErrorState";