OptionsModal.tsx
⎇
Raw
1import { type Accessor, batch, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
2import { type AppOptions, AUDIO_BITRATES, FileDisplay, StreamingMode, VIDEO_BITRATES } from "../types";
3import "./OptionsModal.css";
4import "./Dialog.css";
5import "toastify-js/src/toastify.css";
6import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
7import { audioCodecHasBitrate } from "music-server-shared/utils";
8import { createStore } from "solid-js/store";
9import {
10 checkAudioContainerSupport,
11 checkSupport,
12 checkVideoContainerSupport,
13 type Combination,
14 containerAudioCodecs,
15 containerVideoCodecs,
16 hasMediaSource,
17 playableSettings,
18 selectableVideoCodecs,
19 type Support,
20} from "../capabilities";
21import { handleUnauthorized, toast } from "../utils";
22
23interface OptionsModalProps {
24 showOptions: Accessor<boolean>;
25 setShowOptions: Setter<boolean>;
26 options: AppOptions;
27 setOptions: (options: AppOptions) => void;
28}
29
30const STREAMING_MODE_HINTS: Record<StreamingMode, string> = {
31 [StreamingMode.mse]:
32 "Media Source Extensions: the transcode is streamed into a SourceBuffer. The only mode that can seek within a transcode, but limited to the containers and codecs that support MSE.",
33 [StreamingMode.progressive]:
34 "The transcode is streamed straight to the player as it is produced. Plays whatever the browser can decode, but cannot seek.",
35 [StreamingMode.buffered]:
36 "The server transcodes the whole file before sending anything, so the response is a normal sized download instead of a chunked one. Much slower to start, and only needed for browsers that refuse chunked media sources (Safari).",
37};
38
39type Profile = "videoSettings" | "audioSettings";
40
41function bitrateLabel(bitrate: number): string {
42 return bitrate < 1_000_000 ? `${bitrate / 1000} kbps` : `${bitrate / 1_000_000} Mbps`;
43}
44
45function SupportedOption(props: { value: string; support: Support }) {
46 return (
47 <option value={props.value} disabled={!props.support.supported} title={props.support.reason}>
48 {props.support.supported ? props.value : `${props.value} (unsupported)`}
49 </option>
50 );
51}
52
53export default function OptionsModal(props: OptionsModalProps) {
54 //clone, otherwise it shares the reference
55 const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
56 const [optionsPage, setOptionsPage] = createSignal(1);
57 let dialogElement!: HTMLDialogElement;
58 let formElement!: HTMLFormElement;
59
60 const videoSupport = () => checkSupport(newOptions.videoSettings);
61 const audioSupport = () => checkSupport(newOptions.audioSettings);
62
63 //keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
64 //pick and never the one they are already on. writing a value a store already holds notifies nobody, so
65 //once the selection is playable this re-runs, finds the same values and settles
66 function coerceProfile(profile: Profile) {
67 const playable = playableSettings<Combination>(newOptions[profile]);
68 batch(() => {
69 setNewOptions(profile, "streamingMode", playable.streamingMode);
70 setNewOptions(profile, "container", playable.container);
71 setNewOptions(profile, "audioCodec", playable.audioCodec);
72 //only videoSettings has a video codec, which is exactly when playableSettings returns one
73 if (playable.videoCodec !== undefined) setNewOptions("videoSettings", "videoCodec", playable.videoCodec);
74 });
75 }
76 createEffect(() => coerceProfile("videoSettings"));
77 createEffect(() => coerceProfile("audioSettings"));
78
79 createEffect(
80 on(props.showOptions, () => {
81 if (props.showOptions()) {
82 dialogElement.showModal();
83 setNewOptions(props.options);
84 setOptionsPage(1);
85 }
86 }),
87 );
88
89 function handleSave(e: Event) {
90 e.preventDefault();
91 if (!formElement.reportValidity()) return;
92 toast("Saved changes", "success");
93 props.setOptions(newOptions);
94 handleClose();
95 }
96
97 function handleClose() {
98 dialogElement.close();
99 props.setShowOptions(false);
100 }
101
102 async function deleteServerCache() {
103 const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
104 if (handleUnauthorized(response)) return;
105 if (response?.status !== 200) {
106 toast("Clearing server cache failed", "error");
107 } else {
108 toast("Server cache cleared", "info");
109 }
110 }
111
112 return (
113 <dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
114 <form class="option-content" ref={formElement}>
115 <div class="title">Player Options</div>
116 <hr />
117 <div class="options-page-buttons">
118 <button
119 onClick={(e) => {
120 e.preventDefault();
121 return setOptionsPage(1);
122 }}
123 classList={{ "page-button-active": optionsPage() === 1 }}
124 >
125 1
126 </button>
127 <button
128 onClick={(e) => {
129 e.preventDefault();
130 return setOptionsPage(2);
131 }}
132 classList={{ "page-button-active": optionsPage() === 2 }}
133 >
134 2
135 </button>
136 <button
137 onClick={(e) => {
138 e.preventDefault();
139 return setOptionsPage(3);
140 }}
141 classList={{ "page-button-active": optionsPage() === 3 }}
142 >
143 3
144 </button>
145 {newOptions.enableTranscoding && (
146 <button
147 onClick={(e) => {
148 e.preventDefault();
149 return setOptionsPage(4);
150 }}
151 classList={{ "page-button-active": optionsPage() === 4 }}
152 >
153 4
154 </button>
155 )}
156 {newOptions.enableTranscoding && (
157 <button
158 onClick={(e) => {
159 e.preventDefault();
160 return setOptionsPage(5);
161 }}
162 classList={{ "page-button-active": optionsPage() === 5 }}
163 >
164 5
165 </button>
166 )}
167 </div>
168 <Switch>
169 <Match when={optionsPage() === 1}>
170 <div>
171 <label for="transcoding-checkbox">Enable transcoding:</label>
172 <input
173 id="transcoding-checkbox"
174 type="checkbox"
175 checked={newOptions.enableTranscoding}
176 onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
177 />
178 </div>
179 <div>
180 <label for="language-input">Language preference:</label>
181 <input
182 id="language-input"
183 type="text"
184 placeholder="en,de"
185 title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. Three-letter codes like 'eng' do not match."
186 value={newOptions.languages}
187 onChange={(e) => setNewOptions("languages", e.target.value)}
188 disabled={!newOptions.enableTranscoding}
189 />
190 </div>
191 <div>
192 <label for="max-transcodes-input">Max concurrent transcodes:</label>
193 <input
194 id="max-transcodes-input"
195 type="number"
196 min={1}
197 required
198 value={newOptions.maxTranscodes}
199 disabled={!newOptions.enableTranscoding}
200 onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
201 />
202 </div>
203 <div>
204 <label for="max-downloads-input">Max concurrent downloads:</label>
205 <input
206 id="max-downloads-input"
207 type="number"
208 min={1}
209 required
210 value={newOptions.maxDownloads}
211 onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
212 />
213 </div>
214 </Match>
215 <Match when={optionsPage() === 2}>
216 <div>
217 <label for="hide-volume-checkbox">Hide volume:</label>
218 <input
219 id="hide-volume-checkbox"
220 type="checkbox"
221 checked={newOptions.hideVolume}
222 onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
223 />
224 </div>
225 <div>
226 <label for="hide-cover-checkbox">Hide file browser cover:</label>
227 <input
228 id="hide-cover-checkbox"
229 type="checkbox"
230 checked={newOptions.hideCover}
231 onChange={(e) => setNewOptions("hideCover", e.target.checked)}
232 />
233 </div>
234 <div>
235 <label for="hide-playlist-cover-checkbox">Hide current track cover:</label>
236 <input
237 id="hide-playlist-cover-checkbox"
238 type="checkbox"
239 checked={newOptions.hidePlaylistCover}
240 onChange={(e) => setNewOptions("hidePlaylistCover", e.target.checked)}
241 />
242 </div>
243 <div>
244 <label for="filebrowser-display-select">Filebrowser:</label>
245 <select
246 id="filebrowser-display-select"
247 value={newOptions.filebrowserDisplay}
248 onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value, 10))}
249 >
250 <option value={FileDisplay.FILENAME}>Filename</option>
251 <option value={FileDisplay.TITLE}>Media title</option>
252 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
253 </select>
254 </div>
255 <div>
256 <label for="playlist-display-select">Playlist:</label>
257 <select
258 id="playlist-display-select"
259 value={newOptions.playlistDisplay}
260 onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value, 10))}
261 >
262 <option value={FileDisplay.FILENAME}>Filename</option>
263 <option value={FileDisplay.TITLE}>Media title</option>
264 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
265 </select>
266 </div>
267 <div>
268 <label for="controls-display-select">Current track:</label>
269 <select
270 id="controls-display-select"
271 value={newOptions.controlsDisplay}
272 onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value, 10))}
273 >
274 <option value={FileDisplay.FILENAME}>Filename</option>
275 <option value={FileDisplay.TITLE}>Media title</option>
276 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
277 </select>
278 </div>
279
280 <div>
281 <label for="font-resolution-select">Font resolution:</label>
282 <select
283 id="font-resolution-select"
284 value={newOptions.fontResolution}
285 onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value, 10))}
286 >
287 <option value={10}>10px</option>
288 <option value={12}>12px</option>
289 <option value={16}>16px</option>
290 </select>
291 </div>
292 <div>
293 <label for="progress-height-input">Progress bar height:</label>
294 <input
295 id="progress-height-input"
296 type="number"
297 min={0}
298 required
299 value={newOptions.progressBarHeight}
300 onChange={(e) => setNewOptions("progressBarHeight", e.target.valueAsNumber)}
301 />
302 </div>
303 </Match>
304 <Match when={optionsPage() === 3}>
305 <div>
306 <label for="remember-dir-checkbox">Remember last directory:</label>
307 <input
308 id="remember-dir-checkbox"
309 type="checkbox"
310 checked={newOptions.rememberDir}
311 onChange={(e) => setNewOptions("rememberDir", e.target.checked)}
312 />
313 </div>
314 <div>
315 <label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
316 <input
317 id="duplicate-checkbox"
318 type="checkbox"
319 checked={newOptions.duplicateWarning}
320 onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
321 />
322 </div>
323 <div>
324 <label for="transcode-covers-checkbox">Transcode covers to avif:</label>
325 <input
326 id="transcode-covers-checkbox"
327 type="checkbox"
328 checked={newOptions.transcodeCovers}
329 onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
330 />
331 </div>
332 </Match>
333 <Match when={optionsPage() === 4}>
334 <div style={{ "font-size": "1.2rem" }} class="title">
335 Transcoding settings for videos
336 </div>
337 <div>
338 <label for="video-streaming-mode-select">Streaming mode:</label>
339 <select
340 id="video-streaming-mode-select"
341 title={STREAMING_MODE_HINTS[newOptions.videoSettings.streamingMode]}
342 value={newOptions.videoSettings.streamingMode}
343 onChange={(e) => setNewOptions("videoSettings", "streamingMode", e.target.value as StreamingMode)}
344 >
345 <For each={Object.values(StreamingMode)}>
346 {(mode) => (
347 <option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
348 {mode}
349 </option>
350 )}
351 </For>
352 </select>
353 </div>
354 <div>
355 <label for="container-select">Container:</label>
356 <select
357 id="container-select"
358 title={videoSupport().reason}
359 value={newOptions.videoSettings.container}
360 onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
361 >
362 <For each={Object.values(MediaContainer)}>
363 {(format) => (
364 <SupportedOption
365 value={format}
366 support={checkVideoContainerSupport({ ...newOptions.videoSettings, container: format })}
367 />
368 )}
369 </For>
370 </select>
371 </div>
372 <Show when={selectableVideoCodecs(newOptions.videoSettings).length > 0}>
373 <div>
374 <label for="video-codec-select">Video codec:</label>
375 <select
376 id="video-codec-select"
377 value={newOptions.videoSettings.videoCodec}
378 onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
379 >
380 <For each={containerVideoCodecs(newOptions.videoSettings.container)}>
381 {(codec) => (
382 <SupportedOption
383 value={codec}
384 support={checkSupport({ ...newOptions.videoSettings, videoCodec: codec })}
385 />
386 )}
387 </For>
388 </select>
389 </div>
390 </Show>
391 <Show when={newOptions.videoSettings.videoCodec !== VideoCodec.none}>
392 <div>
393 <label for="video-settings-select">Video encoding settings:</label>
394 <select
395 id="video-settings-select"
396 value={newOptions.videoSettings.videoEncodingSetting}
397 onChange={(e) =>
398 setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
399 }
400 >
401 <For each={Object.values(VideoEncodingSetting)}>
402 {(setting) => {
403 return <option value={setting}>{setting}</option>;
404 }}
405 </For>
406 </select>
407 </div>
408 <div>
409 <label for="video-bitrate-select">Video bitrate:</label>
410 <select
411 id="video-bitrate-select"
412 value={newOptions.videoSettings.videoBitrate}
413 onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))}
414 >
415 <For each={VIDEO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
416 </select>
417 </div>
418 </Show>
419 <div>
420 <label for="audio-codec-select">Audio codec:</label>
421 <select
422 id="audio-codec-select"
423 value={newOptions.videoSettings.audioCodec}
424 onChange={(e) => setNewOptions("videoSettings", "audioCodec", e.target.value as AudioCodec)}
425 >
426 <For each={containerAudioCodecs(newOptions.videoSettings.container)}>
427 {(codec) => (
428 <SupportedOption
429 value={codec}
430 support={checkSupport({ ...newOptions.videoSettings, audioCodec: codec })}
431 />
432 )}
433 </For>
434 </select>
435 </div>
436 <Show when={audioCodecHasBitrate(newOptions.videoSettings.audioCodec)}>
437 <div>
438 <label for="audio-bitrate-select">Audio bitrate:</label>
439 <select
440 id="audio-bitrate-select"
441 value={newOptions.videoSettings.audioBitrate}
442 onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
443 >
444 <For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
445 </select>
446 </div>
447 </Show>
448 </Match>
449 <Match when={optionsPage() === 5}>
450 <div style={{ "font-size": "1.2rem" }} class="title">
451 Transcoding settings for music
452 </div>
453 <div>
454 <label for="audio-streaming-mode-select">Streaming mode:</label>
455 <select
456 id="audio-streaming-mode-select"
457 title={STREAMING_MODE_HINTS[newOptions.audioSettings.streamingMode]}
458 value={newOptions.audioSettings.streamingMode}
459 onChange={(e) => setNewOptions("audioSettings", "streamingMode", e.target.value as StreamingMode)}
460 >
461 <For each={Object.values(StreamingMode)}>
462 {(mode) => (
463 <option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
464 {mode}
465 </option>
466 )}
467 </For>
468 </select>
469 </div>
470 <div>
471 <label for="music-container-select">Container:</label>
472 <select
473 id="music-container-select"
474 title={audioSupport().reason}
475 value={newOptions.audioSettings.container}
476 onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
477 >
478 <For each={Object.values(MediaContainer)}>
479 {(format) => (
480 <SupportedOption
481 value={format}
482 support={checkAudioContainerSupport({ ...newOptions.audioSettings, container: format })}
483 />
484 )}
485 </For>
486 </select>
487 </div>
488 <div>
489 <label for="music-audio-codec-select">Audio codec:</label>
490 <select
491 id="music-audio-codec-select"
492 value={newOptions.audioSettings.audioCodec}
493 onChange={(e) => setNewOptions("audioSettings", "audioCodec", e.target.value as AudioCodec)}
494 >
495 <For each={containerAudioCodecs(newOptions.audioSettings.container)}>
496 {(codec) => (
497 <SupportedOption
498 value={codec}
499 support={checkSupport({ ...newOptions.audioSettings, audioCodec: codec })}
500 />
501 )}
502 </For>
503 </select>
504 </div>
505 <Show when={audioCodecHasBitrate(newOptions.audioSettings.audioCodec)}>
506 <div>
507 <label for="music-audio-bitrate-select">Audio bitrate:</label>
508 <select
509 id="music-audio-bitrate-select"
510 value={newOptions.audioSettings.audioBitrate}
511 onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
512 >
513 <For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
514 </select>
515 </div>
516 </Show>
517 </Match>
518 </Switch>
519 <div class="option-buttons">
520 <button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
521 Clear server caches
522 </button>
523 <button title="Save settings" type="button" onClick={handleSave}>
524 Save
525 </button>
526 <button title="Cancel changes settings" type="button" class="cancel-button" onClick={handleClose}>
527 Cancel
528 </button>
529 </div>
530 </form>
531 </dialog>
532 );
533}
534