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