chore(player): remove HLS media player components
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { VideoPlayer } from "./VideoPlayer";
|
||||
export { PlayerControls } from "./PlayerControls";
|
||||
export { PlayerLoadingState } from "./PlayerLoadingState";
|
||||
export { PlayerErrorState } from "./PlayerErrorState";
|
||||
Reference in New Issue
Block a user