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 { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
7import { audioCodecHasBitrate } from "music-server-shared/utils";
8import { createStore } from "solid-js/store";
9import {
10 checkContainerSupport,
11 checkSupport,
12 type Combination,
13 containerAudioCodecs,
14 containerVideoCodecs,
15 hasMediaSource,
16 playableSettings,
17 selectableVideoCodecs,
18 type Support,
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">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. Three-letter codes like 'eng' do not match."
175 value={newOptions.languages}
176 onChange={(e) => setNewOptions("languages", e.target.value)}
177 disabled={!newOptions.enableTranscoding}
178 />
179 </div>
180 <div>
181 <label for="max-transcodes-input">Max concurrent transcodes:</label>
182 <input
183 id="max-transcodes-input"
184 type="number"
185 min={1}
186 required
187 value={newOptions.maxTranscodes}
188 disabled={!newOptions.enableTranscoding}
189 onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
190 />
191 </div>
192 <div>
193 <label for="max-downloads-input">Max concurrent downloads:</label>
194 <input
195 id="max-downloads-input"
196 type="number"
197 min={1}
198 required
199 value={newOptions.maxDownloads}
200 onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
201 />
202 </div>
203 </Match>
204 <Match when={optionsPage() === 2}>
205 <div>
206 <label for="hide-volume-checkbox">Hide volume:</label>
207 <input
208 id="hide-volume-checkbox"
209 type="checkbox"
210 checked={newOptions.hideVolume}
211 onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
212 />
213 </div>
214 <div>
215 <label for="hide-cover-checkbox">Hide file browser cover:</label>
216 <input
217 id="hide-cover-checkbox"
218 type="checkbox"
219 checked={newOptions.hideCover}
220 onChange={(e) => setNewOptions("hideCover", e.target.checked)}
221 />
222 </div>
223 <div>
224 <label for="hide-playlist-cover-checkbox">Hide current track cover:</label>
225 <input
226 id="hide-playlist-cover-checkbox"
227 type="checkbox"
228 checked={newOptions.hidePlaylistCover}
229 onChange={(e) => setNewOptions("hidePlaylistCover", e.target.checked)}
230 />
231 </div>
232 <div>
233 <label for="filebrowser-display-select">Filebrowser:</label>
234 <select
235 id="filebrowser-display-select"
236 value={newOptions.filebrowserDisplay}
237 onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value, 10))}
238 >
239 <option value={FileDisplay.FILENAME}>Filename</option>
240 <option value={FileDisplay.TITLE}>Media title</option>
241 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
242 </select>
243 </div>
244 <div>
245 <label for="playlist-display-select">Playlist:</label>
246 <select
247 id="playlist-display-select"
248 value={newOptions.playlistDisplay}
249 onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value, 10))}
250 >
251 <option value={FileDisplay.FILENAME}>Filename</option>
252 <option value={FileDisplay.TITLE}>Media title</option>
253 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
254 </select>
255 </div>
256 <div>
257 <label for="controls-display-select">Current track:</label>
258 <select
259 id="controls-display-select"
260 value={newOptions.controlsDisplay}
261 onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value, 10))}
262 >
263 <option value={FileDisplay.FILENAME}>Filename</option>
264 <option value={FileDisplay.TITLE}>Media title</option>
265 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
266 </select>
267 </div>
268
269 <div>
270 <label for="font-resolution-select">Font resolution:</label>
271 <select
272 id="font-resolution-select"
273 value={newOptions.fontResolution}
274 onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value, 10))}
275 >
276 <option value={10}>10px</option>
277 <option value={12}>12px</option>
278 <option value={16}>16px</option>
279 </select>
280 </div>
281 <div>
282 <label for="progress-height-input">Progress bar height:</label>
283 <input
284 id="progress-height-input"
285 type="number"
286 min={0}
287 required
288 value={newOptions.progressBarHeight}
289 onChange={(e) => setNewOptions("progressBarHeight", e.target.valueAsNumber)}
290 />
291 </div>
292 </Match>
293 <Match when={optionsPage() === 3}>
294 <div>
295 <label for="remember-dir-checkbox">Remember last directory:</label>
296 <input
297 id="remember-dir-checkbox"
298 type="checkbox"
299 checked={newOptions.rememberDir}
300 onChange={(e) => setNewOptions("rememberDir", e.target.checked)}
301 />
302 </div>
303 <div>
304 <label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
305 <input
306 id="duplicate-checkbox"
307 type="checkbox"
308 checked={newOptions.duplicateWarning}
309 onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
310 />
311 </div>
312 <div>
313 <label for="transcode-covers-checkbox">Transcode covers to avif:</label>
314 <input
315 id="transcode-covers-checkbox"
316 type="checkbox"
317 checked={newOptions.transcodeCovers}
318 onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
319 />
320 </div>
321 </Match>
322 <Match when={optionsPage() === 4}>
323 <div style={{ "font-size": "1.2rem" }} class="title">
324 Transcoding settings for videos
325 </div>
326 <div>
327 <label for="video-streaming-mode-select">Streaming mode:</label>
328 <select
329 id="video-streaming-mode-select"
330 title={STREAMING_MODE_HINTS[newOptions.videoSettings.streamingMode]}
331 value={newOptions.videoSettings.streamingMode}
332 onChange={(e) => setNewOptions("videoSettings", "streamingMode", e.target.value as StreamingMode)}
333 >
334 <For each={Object.values(StreamingMode)}>
335 {(mode) => (
336 <option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
337 {mode}
338 </option>
339 )}
340 </For>
341 </select>
342 </div>
343 <div>
344 <label for="container-select">Container:</label>
345 <select
346 id="container-select"
347 title={checkSupport(newOptions.videoSettings).reason}
348 value={newOptions.videoSettings.container}
349 onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
350 >
351 <For each={Object.values(MediaContainer)}>
352 {(format) => (
353 <SupportedOption
354 value={format}
355 support={checkContainerSupport({ ...newOptions.videoSettings, container: format })}
356 />
357 )}
358 </For>
359 </select>
360 </div>
361 <Show when={selectableVideoCodecs(newOptions.videoSettings).length > 0}>
362 <div>
363 <label for="video-codec-select">Video codec:</label>
364 <select
365 id="video-codec-select"
366 value={newOptions.videoSettings.videoCodec}
367 onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
368 >
369 <For each={containerVideoCodecs(newOptions.videoSettings.container)}>
370 {(codec) => (
371 <SupportedOption
372 value={codec}
373 support={checkSupport({ ...newOptions.videoSettings, videoCodec: codec })}
374 />
375 )}
376 </For>
377 </select>
378 </div>
379 </Show>
380 <Show when={newOptions.videoSettings.videoCodec !== VideoCodec.none}>
381 <div>
382 <label for="video-settings-select">Video encoding settings:</label>
383 <select
384 id="video-settings-select"
385 value={newOptions.videoSettings.videoEncodingSetting}
386 onChange={(e) =>
387 setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
388 }
389 >
390 <For each={Object.values(VideoEncodingSetting)}>
391 {(setting) => {
392 return <option value={setting}>{setting}</option>;
393 }}
394 </For>
395 </select>
396 </div>
397 <div>
398 <label for="video-bitrate-select">Video bitrate:</label>
399 <select
400 id="video-bitrate-select"
401 value={newOptions.videoSettings.videoBitrate}
402 onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))}
403 >
404 <For each={VIDEO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
405 </select>
406 </div>
407 </Show>
408 <div>
409 <label for="audio-codec-select">Audio codec:</label>
410 <select
411 id="audio-codec-select"
412 value={newOptions.videoSettings.audioCodec}
413 onChange={(e) => setNewOptions("videoSettings", "audioCodec", e.target.value as AudioCodec)}
414 >
415 <For each={containerAudioCodecs(newOptions.videoSettings.container)}>
416 {(codec) => (
417 <SupportedOption
418 value={codec}
419 support={checkSupport({ ...newOptions.videoSettings, audioCodec: codec })}
420 />
421 )}
422 </For>
423 </select>
424 </div>
425 <Show when={audioCodecHasBitrate(newOptions.videoSettings.audioCodec)}>
426 <div>
427 <label for="audio-bitrate-select">Audio bitrate:</label>
428 <select
429 id="audio-bitrate-select"
430 value={newOptions.videoSettings.audioBitrate}
431 onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
432 >
433 <For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
434 </select>
435 </div>
436 </Show>
437 </Match>
438 <Match when={optionsPage() === 5}>
439 <div style={{ "font-size": "1.2rem" }} class="title">
440 Transcoding settings for music
441 </div>
442 <div>
443 <label for="audio-streaming-mode-select">Streaming mode:</label>
444 <select
445 id="audio-streaming-mode-select"
446 title={STREAMING_MODE_HINTS[newOptions.audioSettings.streamingMode]}
447 value={newOptions.audioSettings.streamingMode}
448 onChange={(e) => setNewOptions("audioSettings", "streamingMode", e.target.value as StreamingMode)}
449 >
450 <For each={Object.values(StreamingMode)}>
451 {(mode) => (
452 <option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
453 {mode}
454 </option>
455 )}
456 </For>
457 </select>
458 </div>
459 <div>
460 <label for="music-container-select">Container:</label>
461 <select
462 id="music-container-select"
463 title={checkSupport(newOptions.audioSettings).reason}
464 value={newOptions.audioSettings.container}
465 onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
466 >
467 <For each={Object.values(MediaContainer)}>
468 {(format) => (
469 <SupportedOption
470 value={format}
471 support={checkContainerSupport({ ...newOptions.audioSettings, container: format })}
472 />
473 )}
474 </For>
475 </select>
476 </div>
477 <div>
478 <label for="music-audio-codec-select">Audio codec:</label>
479 <select
480 id="music-audio-codec-select"
481 value={newOptions.audioSettings.audioCodec}
482 onChange={(e) => setNewOptions("audioSettings", "audioCodec", e.target.value as AudioCodec)}
483 >
484 <For each={containerAudioCodecs(newOptions.audioSettings.container)}>
485 {(codec) => (
486 <SupportedOption
487 value={codec}
488 support={checkSupport({ ...newOptions.audioSettings, audioCodec: codec })}
489 />
490 )}
491 </For>
492 </select>
493 </div>
494 <Show when={audioCodecHasBitrate(newOptions.audioSettings.audioCodec)}>
495 <div>
496 <label for="music-audio-bitrate-select">Audio bitrate:</label>
497 <select
498 id="music-audio-bitrate-select"
499 value={newOptions.audioSettings.audioBitrate}
500 onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
501 >
502 <For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
503 </select>
504 </div>
505 </Show>
506 </Match>
507 </Switch>
508 <div class="option-buttons">
509 <button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
510 Clear server caches
511 </button>
512 <button title="Save settings" type="button" onClick={handleSave}>
513 Save
514 </button>
515 <button title="Cancel changes settings" type="button" class="cancel-button" onClick={handleClose}>
516 Cancel
517 </button>
518 </div>
519 </form>
520 </dialog>
521 );
522}
523