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