OptionsModal.tsx
⎇
Raw
1import { type Accessor, createEffect, createSignal, For, type JSX, Match, 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 { MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
7import { audioCodecHasBitrate } from "music-server-shared/utils";
8import { createStore } from "solid-js/store";
9import {
10 type Combination,
11 checkContainerSupport,
12 checkSupport,
13 containerAudioCodecs,
14 containerVideoCodecs,
15 hasMediaSource,
16 playableSettings,
17 type Support,
18 selectableVideoCodecs,
19} from "../capabilities";
20import { handleUnauthorized, toast } from "../utils";
21import { Modal } from "./Modal";
22
23interface OptionsModalProps {
24 showOptions: Accessor<boolean>;
25 setShowOptions: Setter<boolean>;
26 options: AppOptions;
27 setOptions: (options: AppOptions) => void;
28}
29
30const STREAMING_MODE_HINTS: Record<StreamingMode, string> = {
31 [StreamingMode.mse]:
32 "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.",
33 [StreamingMode.progressive]:
34 "The transcode is streamed straight to the player as it is produced. Plays whatever the browser can decode, but cannot seek.",
35 [StreamingMode.buffered]:
36 "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).",
37};
38
39type Profile = "videoSettings" | "audioSettings";
40
41function bitrateLabel(bitrate: number): string {
42 return bitrate < 1_000_000 ? `${bitrate / 1000} kbps` : `${bitrate / 1_000_000} Mbps`;
43}
44
45function SupportedOption(props: { value: string; support: Support }) {
46 return (
47 <option value={props.value} disabled={!props.support.supported} title={props.support.reason}>
48 {props.support.supported ? props.value : `${props.value} (unsupported)`}
49 </option>
50 );
51}
52
53//all three display settings offer the same choices
54function FileDisplaySelect(props: {
55 id: string;
56 label: string;
57 value: FileDisplay;
58 onPick: (value: FileDisplay) => void;
59}) {
60 return (
61 <div>
62 <label for={props.id}>{props.label}</label>
63 <select id={props.id} value={props.value} onChange={(e) => props.onPick(Number.parseInt(e.target.value, 10))}>
64 <option value={FileDisplay.FILENAME}>Filename</option>
65 <option value={FileDisplay.TITLE}>Media title</option>
66 <option value={FileDisplay.TRACKNO_TITLE}>Track number + Media title</option>
67 </select>
68 </div>
69 );
70}
71
72//the parts of a transcoding profile that video and music have in common. the video-only controls are
73//passed as children, so they land between the container and the audio codec like they did per-page
74function ProfileSection(props: {
75 title: string;
76 idPrefix: string;
77 settings: AppOptions["videoSettings"] | AppOptions["audioSettings"];
78 setField: (field: "streamingMode" | "container" | "audioCodec" | "audioBitrate", value: string | number) => void;
79 children?: JSX.Element;
80}) {
81 return (
82 <>
83 <div style={{ "font-size": "1.2rem" }} class="title">
84 {props.title}
85 </div>
86 <div>
87 <label for={`${props.idPrefix}-streaming-mode`}>Streaming mode:</label>
88 <select
89 id={`${props.idPrefix}-streaming-mode`}
90 title={STREAMING_MODE_HINTS[props.settings.streamingMode]}
91 value={props.settings.streamingMode}
92 onChange={(e) => props.setField("streamingMode", e.target.value)}
93 >
94 <For each={Object.values(StreamingMode)}>
95 {(mode) => (
96 <option value={mode} disabled={mode === StreamingMode.mse && !hasMediaSource}>
97 {mode}
98 </option>
99 )}
100 </For>
101 </select>
102 </div>
103 <div>
104 <label for={`${props.idPrefix}-container`}>Container:</label>
105 <select
106 id={`${props.idPrefix}-container`}
107 title={checkSupport(props.settings).reason}
108 value={props.settings.container}
109 onChange={(e) => props.setField("container", e.target.value)}
110 >
111 <For each={Object.values(MediaContainer)}>
112 {(format) => (
113 <SupportedOption
114 value={format}
115 support={checkContainerSupport({ ...props.settings, container: format })}
116 />
117 )}
118 </For>
119 </select>
120 </div>
121 {props.children}
122 <div>
123 <label for={`${props.idPrefix}-audio-codec`}>Audio codec:</label>
124 <select
125 id={`${props.idPrefix}-audio-codec`}
126 value={props.settings.audioCodec}
127 onChange={(e) => props.setField("audioCodec", e.target.value)}
128 >
129 <For each={containerAudioCodecs(props.settings.container)}>
130 {(codec) => (
131 <SupportedOption value={codec} support={checkSupport({ ...props.settings, audioCodec: codec })} />
132 )}
133 </For>
134 </select>
135 </div>
136 <Show when={audioCodecHasBitrate(props.settings.audioCodec)}>
137 <div>
138 <label for={`${props.idPrefix}-audio-bitrate`}>Audio bitrate:</label>
139 <select
140 id={`${props.idPrefix}-audio-bitrate`}
141 value={props.settings.audioBitrate}
142 onChange={(e) => props.setField("audioBitrate", Number.parseInt(e.target.value, 10))}
143 >
144 <For each={AUDIO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
145 </select>
146 </div>
147 </Show>
148 </>
149 );
150}
151
152export default function OptionsModal(props: OptionsModalProps) {
153 //clone, otherwise it shares the reference
154 const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
155 const [optionsPage, setOptionsPage] = createSignal(1);
156 let formElement!: HTMLFormElement;
157
158 //the last two pages only exist while transcoding is on
159 const pages = () => (newOptions.enableTranscoding ? [1, 2, 3, 4, 5] : [1, 2, 3]);
160
161 //keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
162 //pick and never the one they are already on. the store setter merges key by key and skips values it
163 //already holds, so a settled selection writes nothing, notifies nobody and this re-run is the last
164 function coerceProfile(profile: Profile) {
165 setNewOptions(profile, playableSettings<Combination>(newOptions[profile]));
166 }
167 createEffect(() => coerceProfile("videoSettings"));
168 createEffect(() => coerceProfile("audioSettings"));
169
170 function handleSave(close: () => void) {
171 if (!formElement.reportValidity()) return;
172 toast("Saved changes", "success");
173 props.setOptions(newOptions);
174 close();
175 }
176
177 async function deleteServerCache() {
178 const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
179 if (handleUnauthorized(response)) return;
180 if (response?.status !== 200) {
181 toast("Clearing server cache failed", "error");
182 } else {
183 toast("Server cache cleared", "info");
184 }
185 }
186
187 return (
188 <Modal
189 show={props.showOptions}
190 setShow={props.setShowOptions}
191 onOpen={() => {
192 setNewOptions(props.options);
193 setOptionsPage(1);
194 }}
195 >
196 {(close) => (
197 <form class="option-content" ref={formElement}>
198 <div class="title">Player Options</div>
199 <hr />
200 <div class="options-page-buttons">
201 <For each={pages()}>
202 {(page) => (
203 <button
204 onClick={(e) => {
205 e.preventDefault();
206 setOptionsPage(page);
207 }}
208 classList={{ "page-button-active": optionsPage() === page }}
209 >
210 {page}
211 </button>
212 )}
213 </For>
214 </div>
215 <Switch>
216 <Match when={optionsPage() === 1}>
217 <div>
218 <label for="transcoding-checkbox">Enable transcoding:</label>
219 <input
220 id="transcoding-checkbox"
221 type="checkbox"
222 checked={newOptions.enableTranscoding}
223 onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
224 />
225 </div>
226 <div>
227 <label for="language-input">Subtitle language preference:</label>
228 <input
229 id="language-input"
230 type="text"
231 placeholder="en,de"
232 title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching subtitle is selected by default; no match leaves subtitles off."
233 value={newOptions.languages}
234 onChange={(e) => setNewOptions("languages", e.target.value)}
235 />
236 </div>
237 <div>
238 <label for="audio-language-input">Audio language preference:</label>
239 <input
240 id="audio-language-input"
241 type="text"
242 placeholder="en,de"
243 title="Two-letter language codes (ISO 639-1), separated by commas, most preferred first. A matching audio track is selected during transcoding; no match uses the source default track."
244 value={newOptions.audioLanguagePreference}
245 onChange={(e) => setNewOptions("audioLanguagePreference", e.target.value)}
246 disabled={!newOptions.enableTranscoding}
247 />
248 </div>
249 <div>
250 <label for="max-transcodes-input">Max concurrent transcodes:</label>
251 <input
252 id="max-transcodes-input"
253 type="number"
254 min={1}
255 required
256 value={newOptions.maxTranscodes}
257 disabled={!newOptions.enableTranscoding}
258 onChange={(e) => setNewOptions("maxTranscodes", e.target.valueAsNumber)}
259 />
260 </div>
261 <div>
262 <label for="max-downloads-input">Max concurrent downloads:</label>
263 <input
264 id="max-downloads-input"
265 type="number"
266 min={1}
267 required
268 value={newOptions.maxDownloads}
269 onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
270 />
271 </div>
272 </Match>
273 <Match when={optionsPage() === 2}>
274 <div>
275 <label for="hide-volume-checkbox">Hide volume:</label>
276 <input
277 id="hide-volume-checkbox"
278 type="checkbox"
279 checked={newOptions.hideVolume}
280 onChange={(e) => setNewOptions("hideVolume", e.target.checked)}
281 />
282 </div>
283 <div>
284 <label for="hide-cover-checkbox">Hide file browser cover:</label>
285 <input
286 id="hide-cover-checkbox"
287 type="checkbox"
288 checked={newOptions.hideCover}
289 onChange={(e) => setNewOptions("hideCover", e.target.checked)}
290 />
291 </div>
292 <div>
293 <label for="hide-playlist-cover-checkbox">Hide current track cover:</label>
294 <input
295 id="hide-playlist-cover-checkbox"
296 type="checkbox"
297 checked={newOptions.hidePlaylistCover}
298 onChange={(e) => setNewOptions("hidePlaylistCover", e.target.checked)}
299 />
300 </div>
301 <FileDisplaySelect
302 id="filebrowser-display-select"
303 label="Filebrowser:"
304 value={newOptions.filebrowserDisplay}
305 onPick={(value) => setNewOptions("filebrowserDisplay", value)}
306 />
307 <FileDisplaySelect
308 id="playlist-display-select"
309 label="Playlist:"
310 value={newOptions.playlistDisplay}
311 onPick={(value) => setNewOptions("playlistDisplay", value)}
312 />
313 <FileDisplaySelect
314 id="controls-display-select"
315 label="Current track:"
316 value={newOptions.controlsDisplay}
317 onPick={(value) => setNewOptions("controlsDisplay", value)}
318 />
319 <div>
320 <label for="font-resolution-select">Font resolution:</label>
321 <select
322 id="font-resolution-select"
323 value={newOptions.fontResolution}
324 onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value, 10))}
325 >
326 <option value={10}>10px</option>
327 <option value={12}>12px</option>
328 <option value={16}>16px</option>
329 </select>
330 </div>
331 <div>
332 <label for="progress-height-input">Progress bar height:</label>
333 <input
334 id="progress-height-input"
335 type="number"
336 min={0}
337 required
338 value={newOptions.progressBarHeight}
339 onChange={(e) => setNewOptions("progressBarHeight", e.target.valueAsNumber)}
340 />
341 </div>
342 </Match>
343 <Match when={optionsPage() === 3}>
344 <div>
345 <label for="remember-dir-checkbox">Remember last directory:</label>
346 <input
347 id="remember-dir-checkbox"
348 type="checkbox"
349 checked={newOptions.rememberDir}
350 onChange={(e) => setNewOptions("rememberDir", e.target.checked)}
351 />
352 </div>
353 <div>
354 <label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
355 <input
356 id="duplicate-checkbox"
357 type="checkbox"
358 checked={newOptions.duplicateWarning}
359 onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
360 />
361 </div>
362 <div>
363 <label for="transcode-covers-checkbox">Transcode covers to avif:</label>
364 <input
365 id="transcode-covers-checkbox"
366 type="checkbox"
367 checked={newOptions.transcodeCovers}
368 onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
369 />
370 </div>
371 </Match>
372 <Match when={optionsPage() === 4}>
373 <ProfileSection
374 title="Transcoding settings for videos"
375 idPrefix="video"
376 settings={newOptions.videoSettings}
377 setField={(field, value) =>
378 setNewOptions("videoSettings", field as "container", value as MediaContainer)
379 }
380 >
381 <Show when={selectableVideoCodecs(newOptions.videoSettings).length > 0}>
382 <div>
383 <label for="video-codec-select">Video codec:</label>
384 <select
385 id="video-codec-select"
386 value={newOptions.videoSettings.videoCodec}
387 onChange={(e) => setNewOptions("videoSettings", "videoCodec", e.target.value as VideoCodec)}
388 >
389 <For each={containerVideoCodecs(newOptions.videoSettings.container)}>
390 {(codec) => (
391 <SupportedOption
392 value={codec}
393 support={checkSupport({ ...newOptions.videoSettings, videoCodec: codec })}
394 />
395 )}
396 </For>
397 </select>
398 </div>
399 </Show>
400 <Show when={newOptions.videoSettings.videoCodec !== VideoCodec.none}>
401 <div>
402 <label for="video-settings-select">Video encoding settings:</label>
403 <select
404 id="video-settings-select"
405 value={newOptions.videoSettings.videoEncodingSetting}
406 onChange={(e) =>
407 setNewOptions("videoSettings", "videoEncodingSetting", e.target.value as VideoEncodingSetting)
408 }
409 >
410 <For each={Object.values(VideoEncodingSetting)}>
411 {(setting) => <option value={setting}>{setting}</option>}
412 </For>
413 </select>
414 </div>
415 <div>
416 <label for="video-bitrate-select">Video bitrate:</label>
417 <select
418 id="video-bitrate-select"
419 value={newOptions.videoSettings.videoBitrate}
420 onChange={(e) =>
421 setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))
422 }
423 >
424 <For each={VIDEO_BITRATES}>{(rate) => <option value={rate}>{bitrateLabel(rate)}</option>}</For>
425 </select>
426 </div>
427 </Show>
428 </ProfileSection>
429 </Match>
430 <Match when={optionsPage() === 5}>
431 <ProfileSection
432 title="Transcoding settings for music"
433 idPrefix="music"
434 settings={newOptions.audioSettings}
435 setField={(field, value) =>
436 setNewOptions("audioSettings", field as "container", value as MediaContainer)
437 }
438 />
439 </Match>
440 </Switch>
441 <div class="option-buttons">
442 <button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
443 Clear server caches
444 </button>
445 <button title="Save settings" type="button" onClick={() => handleSave(close)}>
446 Save
447 </button>
448 <button title="Cancel changes settings" type="button" class="cancel-button" onClick={close}>
449 Cancel
450 </button>
451 </div>
452 </form>
453 )}
454 </Modal>
455 );
456}
457