import { Info, Maximize, Minimize, Pause, PictureInPicture2, Play, RotateCcw, RotateCw, SkipBack, SkipForward, } from "lucide-react"; import { CircleButton } from "./CircleButton"; import { SeekBar, formatTime } from "./SeekBar"; import { VolumeControl } from "./VolumeControl"; import { QualityMenu } from "./QualityMenu"; import { SubtitleMenu } from "./SubtitleMenu"; import { AudioTrackMenu } from "./AudioTrackMenu"; import { ChaptersMenu } from "./ChaptersMenu"; import type { PlayerAudioTrack, PlayerChapter, PlayerSubtitleInfo, QualityOption } from "../types"; import type { VersionInfo } from "./QualityMenu"; import type { PlayerConfig } from "../context/PlayerConfigContext"; interface PlayerControlsProps { // Visibility visible: boolean; // Video state playing: boolean; currentTime: number; duration: number; buffered: TimeRanges | null; // Seek bar markers chapters?: PlayerChapter[]; introRegion?: { start: number; end: number } | null; creditsRegion?: { start: number; end: number } | null; volume: number; muted: boolean; isFullscreen: boolean; // Subtitles subtitleTracks: PlayerSubtitleInfo[]; activeSubtitleIndex: number | null; onSubtitleSelect: (index: number | null) => void; subtitleDelayMs: number; onSubtitleDelayChange: (ms: number) => void; mediaFileId?: number; playerConfig?: PlayerConfig; onRefreshSubtitles?: () => void; // Audio audioTracks: PlayerAudioTrack[]; activeAudioIndex: number; onAudioSelect?: (index: number, currentPosition: number) => void; // Quality qualityOptions: QualityOption[]; activeQualityId: string; isTranscoding: boolean; qualityError: string | null; onQualitySelect: (id: string) => void; // Version switching versions?: VersionInfo[]; onSwitchVersion?: (fileId: number) => void; // PiP onTogglePiP?: () => void; // Playback info showPlaybackInfo: boolean; onTogglePlaybackInfo: () => void; // Episode navigation hasPrevEpisode?: boolean; hasNextEpisode?: boolean; onPrevEpisode?: () => void; onNextEpisode?: () => void; // Title strip title?: string; subtitleLabel?: string; // Callbacks onPlayPause: () => void; onSeek: (seconds: number) => void; onVolumeChange: (volume: number) => void; onMutedChange: (muted: boolean) => void; onFullscreenToggle: () => void; } /** Skip amount for the ±seconds buttons, matching keyboard shortcuts. */ const SKIP_BACK_SECONDS = 10; const SKIP_FORWARD_SECONDS = 30; export function PlayerControls({ visible, playing, currentTime, duration, buffered, chapters, introRegion, creditsRegion, volume, muted, isFullscreen, subtitleTracks, activeSubtitleIndex, onSubtitleSelect, subtitleDelayMs, onSubtitleDelayChange, mediaFileId, playerConfig, onRefreshSubtitles, audioTracks, activeAudioIndex, onAudioSelect, qualityOptions, activeQualityId, isTranscoding, qualityError, onQualitySelect, versions, onSwitchVersion, onTogglePiP, showPlaybackInfo, onTogglePlaybackInfo, hasPrevEpisode = false, hasNextEpisode = false, onPrevEpisode, onNextEpisode, title, subtitleLabel, onPlayPause, onSeek, onVolumeChange, onMutedChange, onFullscreenToggle, }: PlayerControlsProps) { const safeDuration = duration > 0 ? duration : 0; const handleSkipBack = () => onSeek(Math.max(0, currentTime - SKIP_BACK_SECONDS)); const handleSkipForward = () => onSeek(Math.min(safeDuration || currentTime, currentTime + SKIP_FORWARD_SECONDS)); // When playing any episode in a series (even the first or last), reserve // both prev/next slots so the cluster remains symmetric around the play // button. Movies (no episode nav at all) skip the slots entirely. const showEpisodeSlots = hasPrevEpisode || hasNextEpisode; return (
{/* Gradient scrim — darkens the bottom of the frame just enough that white controls stay legible without washing out the picture. */}
{/* ───── BOTTOM HUD ───── Three-column grid: metadata left, main playback cluster center, utility rail right. Seek bar spans the full width above the row so the playhead is always anchored to the frame edge. */}
e.stopPropagation()} > {/* Grid keeps the playback cluster visually locked to the centerline of the frame regardless of how long the title or utility rail is. `minmax(0,1fr)` forces the side columns to honor 1fr behaviour rather than growing with their content — the cluster stays put. */}
{/* ─── Left: Title / episode / time ─── */}
{title ? (
{title}
) : null}
{subtitleLabel ? ( <> {subtitleLabel} · ) : null} {formatTime(currentTime)} / {formatTime(duration)}
{/* ─── Center: Main playback cluster ─── */} {/* When ANY episode navigation exists (series context), both the prev and next slots are reserved so the play button stays exactly on the cluster's centerline even at the first or last episode. For movies the slots are omitted entirely, leaving a symmetric 3-button cluster that's also perfectly centered. */}
{showEpisodeSlots ? ( hasPrevEpisode ? ( ) : ( ) ) : null} {playing ? ( ) : ( )} {showEpisodeSlots ? ( hasNextEpisode ? ( ) : ( ) ) : null}
{/* ─── Right: Utility rail ─── */}
{onAudioSelect && ( )} {onTogglePiP && document.pictureInPictureEnabled && ( )}
); } /* ───────────────────────────────────────────────────────────────────── Internal building blocks ───────────────────────────────────────────────────────────────────── */ /** Invisible placeholder that reserves the exact footprint of a CircleButton * so the playback cluster stays symmetric when a neighboring episode isn't * available (first/last episode in a series). */ function ClusterSlotSpacer({ size }: { size: "sm" | "md" }) { const sizing = size === "md" ? "h-12 w-12 sm:h-14 sm:w-14" : "h-10 w-10 sm:h-11 sm:w-11"; return