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