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