PlayerControls.tsx
⎇
Raw
1import "./PlayerControls.css";
2import { type Accessor, createEffect, createSignal, For, onMount, type Setter, Show } from "solid-js";
3import type { SetStoreFunction } from "solid-js/store";
4import type { AppOptions, LoadedVideoExtras, PlayerState } from "../types";
5import { formatFilename, formatTime, LocalStorageValues } from "../utils";
6import { LoadingSpinner } from "./LoadingScreen";
7import {
8 ClearSVG,
9 DownSVG,
10 FullscreenSVG,
11 InfoSVG,
12 NextTrackSVG,
13 PauseSVG,
14 PlaySVG,
15 PreviousTrackSVG,
16 RepeatSVG,
17 ShuffleSVG,
18 SquareSVG,
19 TheaterSVG,
20 UpSVG,
21 VolumeSVG,
22} from "./SVG";
23
24type SubtitleSelection = "off" | number;
25
26interface PlayerControlsProps {
27 playerState: PlayerState;
28 setPlayerState: SetStoreFunction<PlayerState>;
29 togglePlay: () => void;
30 stopPlaying: () => void;
31 playPrevious: () => void;
32 playNext: () => void;
33 isShuffled: Accessor<boolean>;
34 setShuffled: Setter<boolean>;
35 isRepeated: Accessor<boolean>;
36 setRepeated: Setter<boolean>;
37 needMSESeek: (seekTo: number) => boolean;
38 seekTo: (seekTo: number) => void;
39 options: AppOptions;
40 videoElement: HTMLVideoElement;
41 isVideoTrack: Accessor<boolean>;
42 videoExtras: Accessor<LoadedVideoExtras | undefined>;
43 selectedSubtitle: Accessor<SubtitleSelection>;
44 setSelectedSubtitle: Setter<SubtitleSelection>;
45 videoTheater: Accessor<boolean>;
46 toggleVideoTheater: () => void;
47}
48
49export default function PlayerControls(props: PlayerControlsProps) {
50 const [controlsDisabled, setControlsDisabled] = createSignal(false);
51 createEffect(() => {
52 setControlsDisabled(!props.playerState.currentTrack);
53 });
54
55 function setVolume(e: Event) {
56 const input = e.target as HTMLInputElement;
57 const volume = Number.parseFloat(input.value);
58 props.setPlayerState("volume", volume);
59
60 if (props.videoElement) {
61 props.videoElement.volume = volume;
62 }
63 }
64
65 function progressClick(e: MouseEvent) {
66 const progressBar = e.currentTarget as HTMLDivElement;
67 const rect = progressBar.getBoundingClientRect();
68 seekTo((e.clientX - rect.left) / rect.width);
69 }
70
71 // Seek to position
72 function seekTo(posPercent: number) {
73 const playerState = props.playerState;
74 if (!props.videoElement || !playerState.currentTrack) return;
75 const duration = playerState.currentTrack.metadata.duration;
76 const seekTime = Math.max(0, posPercent * duration);
77 if (props.needMSESeek(seekTime)) {
78 props.seekTo(seekTime);
79 } else {
80 props.setPlayerState("waitingForStart", true);
81 props.videoElement.currentTime = seekTime;
82 }
83 }
84
85 function seekToChapter(start: number) {
86 const duration = props.playerState.currentTrack?.metadata.duration;
87 if (!duration || !Number.isFinite(start)) return;
88 seekTo(Math.max(0, Math.min(1, start / duration)));
89 }
90
91 //percentage position of a chapter marker; bad metadata can carry chapters beyond the reported
92 //duration, so clamp instead of letting the marker overflow the progress bar
93 function chapterMarkerPosition(start: number): number {
94 const duration = props.playerState.currentTrack?.metadata.duration;
95 if (!duration || !Number.isFinite(start)) return 0;
96 return Math.max(0, Math.min(100, (start / duration) * 100));
97 }
98
99 const [isDragging, setIsDragging] = createSignal(false);
100 const [dragPos, setDragPos] = createSignal(0);
101 createEffect(() => {
102 if (isDragging()) return;
103 setDragPos(
104 props.playerState.currentTrack
105 ? Math.min(
106 100,
107 (props.playerState.currentTime /
108 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
109 100,
110 )
111 : 0,
112 );
113 });
114 let progressContainerRef!: HTMLDivElement;
115 let handleRef!: HTMLDivElement;
116
117 //pointer instead drag events needed, because for some reason e.clientX is always 0 on firefox during the drag
118 function handlePointerDown(e: PointerEvent) {
119 if (e.button !== 0) return;
120 e.preventDefault();
121 handleRef.setPointerCapture(e.pointerId); //needed for mobile drag to work
122 setIsDragging(true);
123 handlePointerMove(e);
124 window.addEventListener("pointermove", handlePointerMove);
125 window.addEventListener("pointerup", handlePointerUp);
126 window.addEventListener("pointercancel", handlePointerUp); //just for safety
127 }
128
129 function handlePointerMove(e: PointerEvent) {
130 if (!isDragging() || !props.playerState.currentTrack) return;
131 e.preventDefault();
132 const rect = progressContainerRef.getBoundingClientRect();
133 const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
134 setDragPos(pos * 100);
135 }
136
137 function handlePointerUp(e: PointerEvent) {
138 if (!isDragging() || !props.playerState.currentTrack) return;
139 setIsDragging(false);
140 if (e.type !== "pointercancel") {
141 const rect = progressContainerRef.getBoundingClientRect();
142 seekTo(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)));
143 }
144 window.removeEventListener("pointermove", handlePointerMove);
145 window.removeEventListener("pointerup", handlePointerUp);
146 window.removeEventListener("pointercancel", handlePointerUp);
147 handleRef.releasePointerCapture(e.pointerId);
148 }
149
150 const [showPlayerControls, setShowPlayerControls] = createSignal(true);
151 onMount(() => {
152 const savePlayerControls = localStorage.getItem(LocalStorageValues.playerControls);
153 if (savePlayerControls !== null) setShowPlayerControls(savePlayerControls === "true");
154 createEffect(() => localStorage.setItem(LocalStorageValues.playerControls, showPlayerControls().toString()));
155 });
156
157 let metadataDialogRef!: HTMLDialogElement;
158
159 function handleClose() {
160 metadataDialogRef.close();
161 }
162
163 function MetadataEntry(label: string, value: unknown, suffix?: string) {
164 if (!value) return undefined;
165 return (
166 <div>
167 <strong>{label}</strong>: {value.toString()}
168 {suffix}
169 <br />
170 </div>
171 );
172 }
173
174 function bitrateDisplay(bitrate: number | undefined, divisor: number) {
175 if (!bitrate) return undefined;
176 return bitrate / divisor;
177 }
178
179 return (
180 <>
181 <dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
182 <div>
183 <div style={{ display: "flex" }}>
184 <div class="title">Metadata</div>
185 <div class="spacer" />
186 <button title="Close dialog" onClick={handleClose}>
187 <ClearSVG />
188 </button>
189 </div>
190 <hr />
191 <div class="metadata-list">
192 {MetadataEntry("Path", props.playerState.currentTrack?.path)}
193 {MetadataEntry("Title", props.playerState.currentTrack?.metadata.title)}
194 {MetadataEntry("Track number", props.playerState.currentTrack?.metadata.trackNumber)}
195 {MetadataEntry("Album", props.playerState.currentTrack?.metadata.album)}
196 {MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
197 {MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
198 {MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
199 {MetadataEntry("Container format", props.playerState.currentTrack?.metadata.container)}
200 {MetadataEntry("Audio codec", props.playerState.currentTrack?.metadata.audioCodec)}
201 {MetadataEntry(
202 "Audio bitrate",
203 bitrateDisplay(props.playerState.currentTrack?.metadata.audioBitrate, 1000),
204 " kbps",
205 )}
206 {MetadataEntry("Video codec", props.playerState.currentTrack?.metadata.videoCodec)}
207 {MetadataEntry(
208 "Video bitrate",
209 bitrateDisplay(props.playerState.currentTrack?.metadata.videoBitrate, 1000 * 1000),
210 " mbps",
211 )}
212 {MetadataEntry(
213 "Video dimensions",
214 props.playerState.currentTrack?.metadata.videoWidth &&
215 props.playerState.currentTrack?.metadata.videoHeight
216 ? `${props.playerState.currentTrack.metadata.videoWidth} × ${props.playerState.currentTrack.metadata.videoHeight}`
217 : undefined,
218 )}
219 {MetadataEntry("Frame rate", props.playerState.currentTrack?.metadata.frameRate, " fps")}
220 {MetadataEntry(
221 "Interlaced",
222 props.playerState.currentTrack?.metadata.interlaced === undefined
223 ? undefined
224 : props.playerState.currentTrack.metadata.interlaced
225 ? "yes"
226 : "no",
227 )}
228 {MetadataEntry("Subtitle tracks", props.videoExtras()?.subtitleTracks.length || undefined)}
229 {MetadataEntry("Chapters", props.videoExtras()?.chapters.length || undefined)}
230 </div>
231 </div>
232 </dialog>
233 <div class="player-controls">
234 <div class="track-info-toggle-container">
235 <button title="Toggle player controls" onclick={() => setShowPlayerControls(!showPlayerControls())}>
236 {showPlayerControls() ? <DownSVG /> : <UpSVG />}
237 </button>
238
239 <div class="track-info">
240 <Show when={props.playerState.currentTrack}>
241 <button title="Show metadata" onClick={() => metadataDialogRef.showModal()}>
242 <InfoSVG />
243 </button>
244 </Show>
245 <span classList={{ "short-name": !showPlayerControls() }}>
246 {props.playerState.currentTrack
247 ? formatFilename(props.playerState.currentTrack, props.options.controlsDisplay)
248 : "No track selected"}
249 </span>
250 {props.playerState.waitingForStart ? (
251 <div>
252 <LoadingSpinner center={true} />
253 </div>
254 ) : (
255 ""
256 )}
257 </div>
258 </div>
259 <div class="progress-container" onClick={progressClick} ref={progressContainerRef}>
260 <div
261 class="progress-bar"
262 style={{
263 cursor: controlsDisabled() ? "default" : undefined,
264 height: `${props.options.progressBarHeight}px`,
265 }}
266 >
267 <For each={props.isVideoTrack() ? props.videoExtras()?.chapters || [] : []}>
268 {(chapter, index) => (
269 <button
270 class="chapter-marker"
271 title={chapter.title || `Chapter ${index() + 1}`}
272 aria-label={`Jump to ${chapter.title || `chapter ${index() + 1}`}`}
273 style={{
274 left: `${chapterMarkerPosition(chapter.start)}%`,
275 }}
276 onClick={(event) => {
277 event.stopPropagation();
278 seekToChapter(chapter.start);
279 }}
280 />
281 )}
282 </For>
283 <For each={props.playerState.buffered}>
284 {(bufferRange) => {
285 return (
286 <div
287 class="buffered-fill"
288 style={{
289 left: `${(bufferRange.start / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
290 right: `${100 - (bufferRange.end / (props.playerState.currentTrack?.metadata.duration as number)) * 100}%`,
291 }}
292 />
293 );
294 }}
295 </For>
296 <div
297 class="progress-fill"
298 style={{
299 width: `${
300 props.playerState.currentTrack
301 ? Math.min(
302 100,
303 (props.playerState.currentTime /
304 (props.playerState.currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
305 100,
306 )
307 : 0
308 }%`,
309 }}
310 />
311 <div
312 class="progress-handle"
313 style={{
314 left: `${dragPos()}%`,
315 height: `${props.options.progressBarHeight * 2}px`,
316 width: `${props.options.progressBarHeight * 2}px`,
317 }}
318 onPointerDown={handlePointerDown}
319 ref={handleRef}
320 />
321 </div>
322 </div>
323 <Show when={showPlayerControls()}>
324 <div class="time-container">
325 <span>{formatTime(props.playerState.currentTime)}</span>/
326 <span>{formatTime(props.playerState.currentTrack?.metadata.duration || 0)}</span>
327 </div>
328
329 <div class="control-buttons">
330 <button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
331 <PreviousTrackSVG disabled={controlsDisabled} />
332 </button>
333 <button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
334 {props.playerState.isPlaying ? (
335 <PauseSVG disabled={controlsDisabled} />
336 ) : (
337 <PlaySVG disabled={controlsDisabled} />
338 )}
339 </button>
340 <button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
341 <NextTrackSVG disabled={controlsDisabled} />
342 </button>
343 <button title="Stop playback" disabled={controlsDisabled()} onClick={props.stopPlaying}>
344 <SquareSVG disabled={controlsDisabled} />
345 </button>
346
347 <button title="Repeat Playlist" onClick={() => props.setRepeated(!props.isRepeated())}>
348 <RepeatSVG disabled={() => !props.isRepeated()} />
349 </button>
350
351 <button title="Shuffle" onClick={() => props.setShuffled(!props.isShuffled())}>
352 <ShuffleSVG disabled={() => !props.isShuffled()} />
353 </button>
354
355 <div class="volume-control" style={props.options.hideVolume ? { display: "none" } : {}}>
356 <VolumeSVG />
357 <input type="range" min="0" max="1" step="0.01" value={props.playerState.volume} onInput={setVolume} />
358 </div>
359 <div>
360 <select
361 value={props.playerState.rate}
362 onchange={(e) => props.setPlayerState("rate", Number.parseFloat(e.target.value))}
363 >
364 <option value={0.25}>0.25x</option>
365 <option value={0.5}>0.5x</option>
366 <option value={0.75}>0.75x</option>
367 <option value={1}>1x</option>
368 <option value={1.25}>1.25x</option>
369 <option value={1.5}>1.5x</option>
370 <option value={1.75}>1.75x</option>
371 <option value={2}>2x</option>
372 <option value={2.5}>2.5x</option>
373 <option value={3}>3x</option>
374 </select>
375 </div>
376 <Show when={props.isVideoTrack() && props.videoExtras()?.subtitleTracks.length}>
377 <select
378 title="Subtitle track"
379 value={props.selectedSubtitle() === "off" ? "off" : props.selectedSubtitle().toString()}
380 onchange={(e) => {
381 const value = e.currentTarget.value;
382 props.setSelectedSubtitle(value === "off" ? value : Number.parseInt(value, 10));
383 }}
384 >
385 <option value="off">Subtitles: off</option>
386 <For each={props.videoExtras()?.subtitleTracks || []}>
387 {(track, index) => (
388 <option value={track.streamIndex}>{track.title || track.language || `Track ${index() + 1}`}</option>
389 )}
390 </For>
391 </select>
392 </Show>
393
394 <Show when={props.isVideoTrack() && props.videoExtras()?.chapters.length}>
395 <select
396 title="Jump to chapter"
397 value=""
398 onchange={(e) => {
399 seekToChapter(Number.parseFloat(e.currentTarget.value));
400 e.currentTarget.value = "";
401 }}
402 >
403 <option value="">Chapters</option>
404 <For each={props.videoExtras()?.chapters || []}>
405 {(chapter, index) => (
406 <option value={chapter.start}>{chapter.title || `Chapter ${index() + 1}`}</option>
407 )}
408 </For>
409 </select>
410 </Show>
411
412 <Show when={props.isVideoTrack()}>
413 <button
414 title={props.videoTheater() ? "Close theater mode" : "Open theater mode"}
415 aria-label={props.videoTheater() ? "Close theater mode" : "Open theater mode"}
416 onClick={props.toggleVideoTheater}
417 >
418 <TheaterSVG />
419 </button>
420 <button
421 title="Toggle video fullscreen"
422 onClick={() => {
423 props.videoElement.controls = true;
424 void props.videoElement.requestFullscreen();
425 }}
426 >
427 <FullscreenSVG />
428 </button>
429 </Show>
430 </div>
431 </Show>
432 </div>
433 </>
434 );
435}
436