import "./PlayerControls.css"; import { type Accessor, createEffect, createSignal, For, type Setter, Show } from "solid-js"; import type { SetStoreFunction } from "solid-js/store"; import type { AppOptions, LoadedVideoExtras, PlayerState } from "../types"; import { formatFilename, formatTime, LocalStorageValues, persistedToggle } from "../utils"; import { LoadingSpinner } from "./LoadingScreen"; import { ClearSVG, DownSVG, FullscreenSVG, InfoSVG, NextTrackSVG, PauseSVG, PlaySVG, PreviousTrackSVG, RepeatSVG, ShuffleSVG, SquareSVG, TheaterSVG, UpSVG, VolumeSVG, } from "./SVG"; type SubtitleSelection = "off" | number; const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]; interface PlayerControlsProps { playerState: PlayerState; setPlayerState: SetStoreFunction; togglePlay: () => void; stopPlaying: () => void; playPrevious: () => void; playNext: () => void; isShuffled: Accessor; setShuffled: Setter; isRepeated: Accessor; setRepeated: Setter; needMSESeek: (seekTo: number) => boolean; seekTo: (seekTo: number) => void; options: AppOptions; videoElement: HTMLVideoElement; isVideoTrack: Accessor; videoExtras: Accessor; selectedSubtitle: Accessor; setSelectedSubtitle: Setter; videoTheater: Accessor; toggleVideoTheater: () => void; } export default function PlayerControls(props: PlayerControlsProps) { const controlsDisabled = () => !props.playerState.currentTrack; function setVolume(e: Event) { const input = e.target as HTMLInputElement; const volume = Number.parseFloat(input.value); props.setPlayerState("volume", volume); if (props.videoElement) { props.videoElement.volume = volume; } } function progressClick(e: MouseEvent) { const progressBar = e.currentTarget as HTMLDivElement; const rect = progressBar.getBoundingClientRect(); seekTo((e.clientX - rect.left) / rect.width); } // Seek to position function seekTo(posPercent: number) { const playerState = props.playerState; if (!props.videoElement || !playerState.currentTrack) return; const duration = playerState.currentTrack.metadata.duration; const seekTime = Math.max(0, posPercent * duration); if (props.needMSESeek(seekTime)) { props.seekTo(seekTime); } else { props.setPlayerState("waitingForStart", true); props.videoElement.currentTime = seekTime; } } function seekToChapter(start: number) { const duration = props.playerState.currentTrack?.metadata.duration; if (!duration || !Number.isFinite(start)) return; seekTo(Math.max(0, Math.min(1, start / duration))); } //percentage position of a chapter marker; bad metadata can carry chapters beyond the reported //duration, so clamp instead of letting the marker overflow the progress bar function chapterMarkerPosition(start: number): number { const duration = props.playerState.currentTrack?.metadata.duration; if (!duration || !Number.isFinite(start)) return 0; return Math.max(0, Math.min(100, (start / duration) * 100)); } //how far along the current track is, in percent const playedPercent = () => { const track = props.playerState.currentTrack; if (!track) return 0; return Math.min(100, (props.playerState.currentTime / (track.metadata.duration || Number.POSITIVE_INFINITY)) * 100); }; const [isDragging, setIsDragging] = createSignal(false); const [dragPos, setDragPos] = createSignal(0); //the handle follows playback except while it is being dragged, when it follows the pointer instead createEffect(() => { if (isDragging()) return; setDragPos(playedPercent()); }); let progressContainerRef!: HTMLDivElement; let handleRef!: HTMLDivElement; //pointer instead drag events needed, because for some reason e.clientX is always 0 on firefox during the drag function handlePointerDown(e: PointerEvent) { if (e.button !== 0) return; e.preventDefault(); handleRef.setPointerCapture(e.pointerId); //needed for mobile drag to work setIsDragging(true); handlePointerMove(e); window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); //just for safety } function handlePointerMove(e: PointerEvent) { if (!isDragging() || !props.playerState.currentTrack) return; e.preventDefault(); const rect = progressContainerRef.getBoundingClientRect(); const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); setDragPos(pos * 100); } function handlePointerUp(e: PointerEvent) { if (!isDragging() || !props.playerState.currentTrack) return; setIsDragging(false); if (e.type !== "pointercancel") { const rect = progressContainerRef.getBoundingClientRect(); seekTo(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))); } window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerUp); handleRef.releasePointerCapture(e.pointerId); } const [showPlayerControls, setShowPlayerControls] = persistedToggle(LocalStorageValues.playerControls, true); let metadataDialogRef!: HTMLDialogElement; function handleClose() { metadataDialogRef.close(); } //every row of the metadata dialog, in display order. a falsy value drops its row function metadataEntries(): [label: string, value: unknown, suffix?: string][] { const track = props.playerState.currentTrack; if (!track) return []; const metadata = track.metadata; const perThousand = (value: number | undefined, divisor: number) => (value ? value / divisor : undefined); return [ ["Path", track.path], ["Title", metadata.title], ["Track number", metadata.trackNumber], ["Album", metadata.album], ["Artist", metadata.artist], ["Genre", metadata.genre?.join(", ")], ["Duration", metadata.duration.toFixed(1), " seconds"], ["Container format", metadata.container], ["Audio codec", metadata.audioCodec], ["Audio bitrate", perThousand(metadata.audioBitrate, 1000), " kbps"], ["Video codec", metadata.videoCodec], ["Video bitrate", perThousand(metadata.videoBitrate, 1000 * 1000), " mbps"], [ "Video dimensions", metadata.videoWidth && metadata.videoHeight ? `${metadata.videoWidth} × ${metadata.videoHeight}` : undefined, ], ["Frame rate", metadata.frameRate, " fps"], ["Interlaced", metadata.interlaced === undefined ? undefined : metadata.interlaced ? "yes" : "no"], ["Subtitle tracks", props.videoExtras()?.subtitleTracks.length || undefined], ["Chapters", props.videoExtras()?.chapters.length || undefined], ]; } return ( <> e.target.nodeName === "DIALOG" && handleClose()}>
Metadata

{props.playerState.currentTrack ? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay) : "No track selected"} {props.playerState.waitingForStart ? (
) : ( "" )}
{(chapter, index) => (
); }