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