OptionsModal.tsx
⎇
Raw
1import { type Accessor, 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 { type AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
7import { audioCodecHasBitrate } from "music-server-shared/utils";
8import { createStore } from "solid-js/store";
9import {
10 type Combination,
11 checkContainerSupport,
12 checkSupport,
13 containerAudioCodecs,
14 containerVideoCodecs,
15 hasMediaSource,
16 playableSettings,
17 type Support,
18 selectableVideoCodecs,
19} from "../capabilities";
20import { handleUnauthorized, toast } from "../utils";
21
22interface OptionsModalProps {
23 showOptions: Accessor<boolean>;
24 setShowOptions: Setter<boolean>;
25 options: AppOptions;
26 setOptions: (options: AppOptions) => void;
27}
28
29const STREAMING_MODE_HINTS: Record<StreamingMode, string> = {
30 [StreamingMode.mse]:
31 "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.",
32 [StreamingMode.progressive]:
33 "The transcode is streamed straight to the player as it is produced. Plays whatever the browser can decode, but cannot seek.",
34 [StreamingMode.buffered]:
35 "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).",
36};
37
38type Profile = "videoSettings" | "audioSettings";
39
40function bitrateLabel(bitrate: number): string {
41 return bitrate < 1_000_000 ? `${bitrate / 1000} kbps` : `${bitrate / 1_000_000} Mbps`;
42}
43
44function SupportedOption(props: { value: string; support: Support }) {
45 return (
46 <option value={props.value} disabled={!props.support.supported} title={props.support.reason}>
47 {props.support.supported ? props.value : `${props.value} (unsupported)`}
48 </option>
49 );
50}
51
52export default function OptionsModal(props: OptionsModalProps) {
53 //clone, otherwise it shares the reference
54 const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
55 const [optionsPage, setOptionsPage] = createSignal(1);
56 let dialogElement!: HTMLDialogElement;
57 let formElement!: HTMLFormElement;
58
59 //keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
60 //pick and never the one they are already on. the store setter merges key by key and skips values it
61 //already holds, so a settled selection writes nothing, notifies nobody and this re-run is the last
62 function coerceProfile(profile: Profile) {
63 setNewOptions(profile, playableSettings<Combination>(newOptions[profile]));
64 }
65 createEffect(() => coerceProfile("videoSettings"));
66 createEffect(() => coerceProfile("audioSettings"));
67
68 createEffect(
69 on(props.showOptions, () => {
70 if (props.showOptions()) {
71 dialogElement.showModal();
72 setNewOptions(props.options);
73 setOptionsPage(1);
74 }
75 }),
76 );
77
78 function handleSave(e: Event) {
79 e.preventDefault();
80 if (!formElement.reportValidity()) return;
81 toast("Saved changes", "success");
82 props.setOptions(newOptions);
83 handleClose();
84 }
85
86 function handleClose() {
87 dialogElement.close();
88 props.setShowOptions(false);
89 }
90
91 async function deleteServerCache() {
92 const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
93 if (handleUnauthorized(response)) return;
94 if (response?.status !== 200) {
95 toast("Clearing server cache failed", "error");
96 } else {
97 toast("Server cache cleared", "info");
98 }
99 }
100
101 return (
102 <dialog ref={dialogElement} onClose={handleClose} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
103 <form class="option-content" ref={formElement}>
104 <div class="title">Player Options</div>
105 <hr />
106 <div class="options-page-buttons">
107 <button
108 onClick={(e) => {
109 e.preventDefault();
110 return setOptionsPage(1);
111 }}
112 classList={{ "page-button-active": optionsPage() === 1 }}
113 >
114 1
115 </button>
116 <button
117 onClick={(e) => {
118 e.preventDefault();
119 return setOptionsPage(2);
120 }}
121 classList={{ "page-button-active": optionsPage() === 2 }}
122 >
123 2
124 </button>
125 <button
126 onClick={(e) => {
127 e.preventDefault();
128 return setOptionsPage(3);
129 }}
130 classList={{ "page-button-active": optionsPage() === 3 }}
131 >
132 3
133 </button>
134 {newOptions.enableTranscoding && (
135 <button
136 onClick={(e) => {
137 e.preventDefault();
138 return setOptionsPage(4);
139 }}
140 classList={{ "page-button-active": optionsPage() === 4 }}
141 >
142 4
143 </button>
144 )}
145 {newOptions.enableTranscoding && (
146 <button
147 onClick={(e) => {
148 e.preventDefault();
149 return setOptionsPage(5);
150 }}
151 classList={{ "page-button-active": optionsPage() === 5 }}
152 >
153 5
154 </button>
155 )}
156 </div>
157 <Switch>
158 <Match when={optionsPage() === 1}>
159 <div>
160 <label for="transcoding-checkbox">Enable transcoding:</label>
161 <input
162 id="transcoding-checkbox"
163 type="checkbox"
164 checked={newOptions.enableTranscoding}
165 onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
166 />
167 </div>
168 <div>
169 <label for="language-input">Subtitle language preference:</label>
170 <input
171 id="language-input"
172 type="text"
173 placeholder="en,de"
174 title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching subtitle is selected by default; no match leaves subtitles off."
175 value={newOptions.languages}
176 onChange={(e) => setNewOptions("languages", e.target.value)}
177 />
178 </div>
179 <div>
180 <label for="audio-language-input">Audio language preference:</label>
181 <input
182 id="audio-language-input"
183 type="text"
184 placeholder="en,de"
185 title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching audio track is selected during transcoding; no match uses the source default track."
186 value={newOptions.audioLanguagePreference}
187 onChange={(e) => setNewOptions("audioLanguagePreference", 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={checkSupport(newOptions.videoSettings).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={checkContainerSupport({ ...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={checkSupport(newOptions.audioSettings).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={checkContainerSupport({ ...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