Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989<script lang="ts">import { deploymentConfig } from "../deploymentConfig.js";import { iconPacks } from "../icons/index.js";import { languages } from "../languages/index.js";import { t } from "../languages/translate.svelte.js";import { motionEnabled, prefersReducedMotion } from "../motion.js";import { offlineManager } from "../offline/offlineManager.svelte.js";import { load, player } from "../player.svelte.js";import { installPwa, pwa } from "../pwa.svelte.js";import { artworkSizesForIconSize, iconSettingsForFontSize, settings,} from "../settings.svelte.js";import { prefersReducedTransparency, transparencyEnabled,} from "../transparency.js";import { ui } from "../ui.svelte.js";import LibrariesSettings from "./LibrariesSettings.svelte";import Modal from "./Modal.svelte";
const tabs = ["servers", "general", "appearance", "columns", "data"] as const;type Tab = (typeof tabs)[number];
let activeTab = $state<Tab>("servers");
let storageUsage = $state<number | null>(null);
async function refreshStorageUsage() { if (!navigator.storage?.estimate) { storageUsage = null; return; } const estimate = await navigator.storage.estimate(); storageUsage = estimate.usage ?? null;}
$effect(() => { if (ui.showSettings && activeTab === "data") { refreshStorageUsage().catch((err) => { console.error("failed to estimate storage:", err); }); }});
function formatBytes(bytes: number | null): string { if (bytes === null) return "Unknown"; if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;}
async function handleDownloadsToggle(event: Event) { const input = event.currentTarget as HTMLInputElement; if (input.checked) { settings.downloads = true; await offlineManager.refreshDownloadsList(); return; } const confirmed = confirm(t("offline.clear_downloads_confirm")); if (!confirmed) { input.checked = true; return; } ui.busy = true; try { settings.downloads = false; settings.downloadsAuto = false; settings.downloadsAutoSyncMode = "none";
if (player.track && offlineManager.isSongDownloaded(player.track)) { await load(player.track, player.time, !player.paused); } await offlineManager.wipeOfflineData(); await refreshStorageUsage(); } catch (err) { settings.downloads = true; console.error("failed to disable downloads:", err); } finally { ui.busy = false; }}
const artSizes = [ { labelKey: "common.artist", key: "artArtist" }, { labelKey: "common.album", key: "artAlbum" }, { labelKey: "common.playlist", key: "artPlaylist" }, { labelKey: "common.radio", key: "artRadio" }, { labelKey: "common.shares", key: "artShare" }, { labelKey: "common.song", key: "artSong" },] as const;
function resetArtworkSizes(iconSize: number) { Object.assign(settings, artworkSizesForIconSize(iconSize, settings));}
let previews = $state({ iconSize: settings.iconSize, artArtist: settings.artArtist, artAlbum: settings.artAlbum, artPlaylist: settings.artPlaylist, artRadio: settings.artRadio, artShare: settings.artShare, artSong: settings.artSong, coverBackgroundBlur: settings.coverBackgroundBlur, fontSize: settings.fontSize, fontFamily: settings.fontFamily, fontFamilyMonospace: settings.fontFamilyMonospace,});
$effect(() => { if (!ui.showSettings) return; previews.iconSize = settings.iconSize; previews.coverBackgroundBlur = settings.coverBackgroundBlur; previews.fontSize = settings.fontSize; previews.fontFamily = settings.fontFamily; previews.fontFamilyMonospace = settings.fontFamilyMonospace; for (const item of artSizes) previews[item.key] = settings[item.key];});
const columnSettings = [ { labelKey: "common.album_artist", key: "enableAlbumArtist" }, { labelKey: "common.artist", key: "enableArtist" }, { labelKey: "common.album", key: "enableAlbum" }, { labelKey: "common.genre", key: "enableGenre" }, { labelKey: "common.year", key: "enableYear" }, { labelKey: "common.time", key: "enableTime" }, { labelKey: "common.quality", key: "enableQuality" }, { labelKey: "common.play_count", key: "enablePlayCount" }, { labelKey: "common.disc_num", key: "enableDisc" }, { labelKey: "common.track_num", key: "enableTrackNum" }, { labelKey: "common.queue_num", key: "enableQueueNum" }, { labelKey: "common.favorite", key: "enableFavorites" }, { labelKey: "common.rating", key: "enableRatings" }, { labelKey: "settings.downloads", key: "enableDownloads" },] as const;</script>
<Modal bind:open={ui.showSettings} title={t("common.settings")} class="settings-modal"> <div class="body"> <header> {#each tabs as tab} <button type="button" class="tab pressable pressable--gentle" class:active={activeTab === tab} onclick={() => (activeTab = tab)} > {t(`settings.${tab}`)} </button> {/each} </header>
<main> {#if activeTab === "servers"} <LibrariesSettings /> {:else if activeTab === "general"} <div class="row"> <span>{t("common.language")}</span> <select bind:value={settings.lang}> <optgroup label="Human reviewed"> <option value={languages[0][0]}>{languages[0][1]}</option> </optgroup> <optgroup label="Written by LLM, unreviewed!!"> {#each languages.slice(1) as [code, label]} <option value={code}>{label}</option> {/each} </optgroup> </select> </div>
<br>
<div class="row"> <span>{t("settings.replaygain")}</span> <select bind:value={settings.replayGainMode}> <option value="off">{t("common.off")}</option> <option value="track">{t("settings.replaygain_track")}</option> <option value="album">{t("settings.replaygain_album")}</option> </select> </div> {#if settings.replayGainMode !== "off"} <div class="sub-group"> <div class="row"> <span >{t("settings.replaygain_preamp")} (<span class="num">{settings.replayGainPreamp} dB</span>)</span > <div class="control"> <input type="range" min="-12" max="12" step="1" bind:value={settings.replayGainPreamp} > </div> </div> </div> {/if} <div class="row"> <span>{t("settings.scrobbling")}</span> <input type="checkbox" bind:checked={settings.scrobbling}> </div> {#if !settings.scrobbling} <div class="sub-group"> <div class="row"> <span>{t("settings.scrobbling_status")}</span> <input type="checkbox" bind:checked={settings.scrobblingStatus}> </div> </div> {/if}
<h3>{t("state.now_playing")}</h3>
<div class="row"> <span>{t("common.lyrics")}</span> <input type="checkbox" bind:checked={settings.lyrics}> </div>
<h3>{t("common.controls")}</h3>
<div class="row"> <span>{t("common.loop")}</span> <input type="checkbox" bind:checked={settings.controlLoop}> </div> <div class="row"> <span>{t("common.miniplayer")}</span> <input type="checkbox" bind:checked={settings.controlMiniplayer}> </div> <div class="row"> <span>{t("common.favorite")}</span> <input type="checkbox" bind:checked={settings.controlFavorite}> </div> <div class="row"> <span>{t("common.rating")}</span> <input type="checkbox" bind:checked={settings.controlRating}> </div> <div class="row"> <span>{t("common.time")}</span> <input type="checkbox" bind:checked={settings.controlTime}> </div> <div class="row"> <span>{t("common.volume")}</span> <input type="checkbox" bind:checked={settings.controlVolume}> </div> {:else if activeTab === "appearance"} <div class="row"> <span>{t("settings.motion")}</span> <input type="checkbox" checked={motionEnabled()} onchange={(event) => (settings.motion = event.currentTarget.checked)} disabled={prefersReducedMotion.current} > </div> <div class="row"> <span>{t("settings.transparency")}</span> <input type="checkbox" checked={transparencyEnabled()} onchange={(event) => (settings.transparency = event.currentTarget.checked)} disabled={prefersReducedTransparency.current} > </div> <div class="row"> <span>{t("settings.rounded_corners")}</span> <input type="checkbox" bind:checked={settings.roundedCorners}> </div> <div class="row"> <span>{t("settings.cover_background")}</span> <select bind:value={settings.coverBackground}> <option value="off">{t("common.off")}</option> <option value="album">{t("common.album")}</option> <option value="artist">{t("common.artist")}</option> </select> </div> {#if settings.coverBackground !== "off"} <div class="sub-group"> <div class="row"> <span >{t("settings.opacity")} (<span class="num" >{Math.round(settings.coverBackgroundOpacity * 100)}%</span >)</span > <div class="control"> <input type="range" min="0.05" max="1" step="0.05" bind:value={settings.coverBackgroundOpacity} > </div> </div> <div class="row"> <span >{t("settings.blur")} (<span class="num">{previews.coverBackgroundBlur}px</span >)</span > <div class="control"> <input type="range" min="0" max="16" step="1" value={settings.coverBackgroundBlur} oninput={(event) => (previews.coverBackgroundBlur = event.currentTarget.valueAsNumber)} onchange={(event) => (settings.coverBackgroundBlur = event.currentTarget.valueAsNumber)} > </div> </div> </div> {/if} <div class="row"> <span>{t("settings.dynamic_colors")}</span> <input type="checkbox" bind:checked={settings.dynamicColors}> </div> <div class="row"> <span>{t("settings.dynamic_favicon")}</span> <input type="checkbox" bind:checked={settings.dynamicFavicon}> </div> <div class="row"> <span>{t("settings.transparent_borders")}</span> <input type="checkbox" bind:checked={settings.transparentOutlines}> </div> <div class="row"> <span>{t("settings.transparent_sidebar")}</span> <input type="checkbox" bind:checked={settings.transparentSidebar}> </div> <div class="row"> <span >{t("settings.sidebar_width")} (<span class="num">{Math.round(settings.sidebarWidth)}rem</span >)</span > <div class="control"> <input type="range" min="12" max="24" step="1" bind:value={settings.sidebarWidth} > </div> </div>
<br>
{#if !CSS.supports("font: -apple-system-body")} <div class="row"> <span>{t("settings.font_size")}</span> <input type="text" value={previews.fontSize} oninput={(event) => (previews.fontSize = event.currentTarget.value)} onchange={(event) => (settings.fontSize = event.currentTarget.value)} placeholder="1rem" > </div> <div class="row"> <span>{t("settings.font_family")}</span> <input type="text" value={previews.fontFamily} oninput={(event) => (previews.fontFamily = event.currentTarget.value)} onchange={(event) => (settings.fontFamily = event.currentTarget.value)} placeholder="ui-sans-serif, system-ui, sans-serif" > </div> <div class="row"> <span>{t("settings.font_family_monospace")}</span> <input type="text" value={previews.fontFamilyMonospace} oninput={(event) => (previews.fontFamilyMonospace = event.currentTarget.value)} onchange={(event) => (settings.fontFamilyMonospace = event.currentTarget.value)} placeholder="ui-monospace, monospace" > </div> <br> {/if}
<div class="row"> <span>{t("settings.icon_pack")}</span> <select value={settings.iconPack} onchange={(e) => { const selectedIconPack = e.currentTarget .value as typeof settings.iconPack; const iconSettings = iconSettingsForFontSize( Number.parseFloat( getComputedStyle(document.documentElement).fontSize, ), selectedIconPack, ); settings.iconPack = selectedIconPack; settings.iconSize = iconSettings.iconPack === selectedIconPack ? iconSettings.iconSize : iconPacks.find((pack) => pack.id === selectedIconPack)?.size ?? 24; resetArtworkSizes(settings.iconSize); }} > {#each iconPacks as pack} <option value={pack.id}>{pack.label}</option> {/each} </select> </div> <div class="row"> <span >{t("settings.icon_size")} (<span class="num">{previews.iconSize}px</span>)</span > <div class="control"> <input type="range" min="16" max="24" step="4" value={settings.iconSize} oninput={(event) => (previews.iconSize = event.currentTarget.valueAsNumber)} onchange={(event) => { const iconSize = event.currentTarget.valueAsNumber; settings.iconSize = iconSize; resetArtworkSizes(iconSize); }} > </div> </div>
<br>
<h3>{t("settings.artwork_sizes")}</h3>
{#each artSizes as item} <div class="row"> <span> {t(item.labelKey)} (<span class="num">{previews[item.key]}px</span>) </span> <div class="control"> <input type="range" min="0" max="64" step="2" value={settings[item.key]} oninput={(event) => (previews[item.key] = event.currentTarget.valueAsNumber)} onchange={(event) => (settings[item.key] = event.currentTarget.valueAsNumber)} > </div> </div> {/each}
<h3>{t("state.now_playing")}</h3>
<div class="row"> <span>{t("settings.visualizer")}</span> <input type="checkbox" bind:checked={settings.visualizer}> </div> {#if settings.visualizer} <div class="sub-group"> <div class="row text-footnote"> ⚠️ {t("settings.webkit_warning")} </div> <div class="row"> <span>{t("settings.style")}</span> <select bind:value={settings.visualizerStyle}> <option value="bars">{t("settings.style_bars")}</option> <option value="wave">{t("settings.style_wave")}</option> <option value="oscilloscope"> {t("settings.style_oscilloscope")} </option> </select> </div> <div class="row"> <span>{t("settings.orientation")}</span> <select bind:value={settings.visualizerOrientation}> <option value="horizontal"> {t("settings.orientation_horizontal")} </option> <option value="vertical"> {t("settings.orientation_vertical")} </option> </select> </div> <div class="row"> <span>{t("settings.placement")}</span> <select bind:value={settings.visualizerPlacement}> {#if settings.visualizerOrientation === "vertical"} <option value="start">{t("settings.placement_left")}</option> <option value="center"> {t("settings.placement_center")} </option> <option value="end">{t("settings.placement_right")}</option> {:else} <option value="start">{t("settings.placement_top")}</option> <option value="center"> {t("settings.placement_center")} </option> <option value="end">{t("settings.placement_bottom")}</option> {/if} </select> </div> <div class="row"> <span>{t("settings.center_text")}</span> <input type="checkbox" disabled={settings.visualizerLocation === "artwork" || settings.visualizerOrientation !== "horizontal" || settings.visualizerPlacement !== "center"} bind:checked={settings.visualizerCenterText} > </div> <div class="row"> <span>{t("settings.location")}</span> <select bind:value={settings.visualizerLocation}> <option value="artwork"> {t("settings.location_artwork")} </option> <option value="now-playing"> {t("settings.location_now_playing")} </option> <option value="background"> {t("settings.location_background")} </option> </select> </div> <div class="row"> <span >{t("settings.size")} (<span class="num" >{Math.round(settings.visualizerSize * 100)}%</span >)</span > <div class="control"> <input type="range" min="0.1" max="2" step="0.05" bind:value={settings.visualizerSize} > </div> </div> <div class="row"> <span >{t("settings.opacity")} (<span class="num" >{Math.round(settings.visualizerOpacity * 100)}%</span >)</span > <div class="control"> <input type="range" min="0.05" max="1" step="0.05" bind:value={settings.visualizerOpacity} > </div> </div> <div class="row"> <span>{t("settings.blend_mode")}</span> <select bind:value={settings.visualizerBlendMode}> <option value="normal">{t("settings.blend_normal")}</option> <option value="difference"> {t("settings.blend_difference")} </option> <option value="overlay">{t("settings.blend_overlay")}</option> </select> </div> <div class="row"> <span>{t("settings.overlay")}</span> <input type="checkbox" bind:checked={settings.visualizerOverlay}> </div> <div class="row"> <span>{t("settings.bins")}</span> <div class="control"> <select bind:value={settings.visualizerBins}> {#each [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384] as step} <option value={step}>{step}</option> {/each} </select> </div> </div> <div class="row"> <span >{t("settings.smoothing")} (<span class="num" >{Math.round(settings.visualizerSmoothing * 100)}%</span >)</span > <div class="control"> <input type="range" min="0" max="0.95" step="0.05" disabled={settings.visualizerStyle === "oscilloscope"} bind:value={settings.visualizerSmoothing} > </div> </div> <div class="row"> <span >{t("settings.stroke")} (<span class="num">{settings.visualizerStroke}</span>)</span > <div class="control"> <input type="range" min="0.5" max="16" step="0.5" disabled={settings.visualizerStyle !== "oscilloscope"} bind:value={settings.visualizerStroke} > </div> </div> </div> {/if}
<h3>{t("common.controls")}</h3>
<div class="row"> <span>{t("settings.progress_bar")}</span> <select bind:value={settings.progressBarStyle}> <option value="native">{t("settings.bar_native")}</option> <option value="simple">{t("settings.bar_simple")}</option> <option value="triangle">{t("settings.bar_triangle")}</option> </select> </div> <div class="row"> <span>{t("settings.volume_bar")}</span> <select bind:value={settings.volumeBarStyle}> <option value="native">{t("settings.bar_native")}</option> <option value="simple">{t("settings.bar_simple")}</option> <option value="triangle">{t("settings.bar_triangle")}</option> </select> </div> {:else if activeTab === "columns"} {#each columnSettings as item} <div class="row"> <span>{t(item.labelKey)}</span> <input type="checkbox" disabled={item.key === "enableDownloads" && !settings.downloads} bind:checked={settings[item.key]} > </div> {/each} {:else if activeTab === "data"} {#if storageUsage !== null} <div class="row"> <span>{t("offline.storage_usage")}</span> <span class="num">{formatBytes(storageUsage)}</span> </div> <div class="row text-footnote"> ℹ️ {t("offline.storage_usage_desc")} </div> <br> {/if}
<div class="row"> <span>{t("settings.transcoding")}</span> <input type="checkbox" disabled={settings.downloadsAuto} bind:checked={settings.transcoding} > </div> {#if settings.transcoding} <div class="sub-group"> {#if settings.downloads} <div class="row text-footnote"> ⚠️ {t("settings.downloaded_songs_not_transcoded")} </div> {/if} <div class="row"> <span>{t("settings.transcode_format")}</span> <input type="text" bind:value={settings.transcodingFormat} placeholder="opus" > </div> <div class="row"> <span>{t("settings.transcode_bitrate")}</span> <input type="number" value={settings.transcodingBitrate ?? ""} oninput={(e) => { const value = (e.currentTarget as HTMLInputElement).value; settings.transcodingBitrate = value === "" ? null : Number(value); }} placeholder="128" > </div> </div> {/if}
<br>
<div class="row"> <span>{t("settings.downloads")}</span> <input type="checkbox" checked={settings.downloads} onchange={handleDownloadsToggle} > </div>
{#if settings.downloads} {@const downloadCounts = offlineManager.downloadCounts} <div class="sub-group"> <div class="row"> <span>{t("common.songs")}</span> <span class="num">{downloadCounts.songs}</span> </div> <div class="row"> <span>{t("common.albums")}</span> <span class="num">{downloadCounts.albums}</span> </div> <div class="row"> <span>{t("common.artists")}</span> <span class="num">{downloadCounts.artists}</span> </div> <div class="row"> <span>{t("common.playlists")}</span> <span class="num">{downloadCounts.playlists}</span> </div> <div class="row"> <span>{t("settings.auto_sync")}</span> <select bind:value={settings.downloadsAutoSyncMode}> <option value="none">{t("common.none")}</option> <option value="scrobbles"> {t("settings.auto_sync_scrobbles")} </option> <option value="all">{t("settings.auto_sync_all")}</option> </select> </div> <div class="row"> <span>{t("settings.auto_download")}</span> <input type="checkbox" bind:checked={settings.downloadsAuto} onchange={(e) => { if (e.currentTarget.checked) { settings.transcoding = false; } }} > </div> {#if settings.downloadsAuto} <div class="sub-group"> {#if settings.downloadsAutoSongs > 6} <div class="row text-footnote"> ⚠️ {t("settings.auto_download_high_warning")} </div> {/if} <div class="row"> <span >{t("settings.auto_download_songs")} (<span class="num">{settings.downloadsAutoSongs}</span >)</span > <div class="control"> <input type="range" min="1" max="16" step="1" bind:value={settings.downloadsAutoSongs} > </div> </div> <div class="row"> <span >{t("settings.auto_download_trigger")} (<span class="num" >{settings.downloadsAutoTrigger === 0 ? t("settings.auto_download_start") : `${settings.downloadsAutoTrigger}%`}</span >)</span > <div class="control"> <input type="range" min="0" max="80" step="5" bind:value={settings.downloadsAutoTrigger} > </div> </div> </div> {/if} <div class="row"> <span>{t("offline.auto_delete")}</span> <input type="checkbox" bind:checked={settings.downloadsAutoDelete} > </div> {#if settings.downloadsAutoDelete} <div class="sub-group"> <div class="row text-footnote"> ℹ️ {t("offline.auto_delete_startup_notice")} </div> <div class="row"> <span >{t("offline.auto_delete_days")} {settings.downloadsAutoDeleteDays} {t( settings.downloadsAutoDeleteDays === 1 ? "common.day" : "common.days", )}</span > <div class="control"> <input type="range" min="1" max="60" step="1" bind:value={settings.downloadsAutoDeleteDays} > </div> </div> </div> {/if} </div> {/if} {/if} </main> </div>
<footer> <span> {#if deploymentConfig.name} {deploymentConfig.name} /{" "} {/if} <a target="_blank" href="https://tangled.org/devins.page/tinysub"> tinysub 2.59.3 </a> </span> {#if pwa.installPrompt} <button class="native" type="button" onclick={installPwa}> {t("common.install")} </button> {/if} </footer></Modal>
<style>.body { display: flex; flex-direction: column; flex: 1; overflow: auto;
@media (width >= 32rem) { flex-direction: row; }}header { display: flex; gap: 0.25rem; overflow: auto hidden; border-block-end: var(--border-subtle); padding: 0.5rem 1rem 0;
@media (width >= 32rem) { gap: 0; flex-direction: column; overflow: hidden auto; border-block-end: none; border-inline-end: var(--border-subtle); padding: 0; inline-size: 14rem; }}.tab { --button-transform-origin: center bottom; background-color: var(--bg-layer); border-radius: var(--radius-sm) var(--radius-sm) 0 0; corner-shape: squircle; padding: 0.25rem 0.75rem; white-space: nowrap;
&.active { opacity: 1; background-color: var(--bg-highlight); color: var(--text-highlight); }
@media (width >= 32rem) { --button-transform-origin: left center; background: none; text-align: start;
:global(:dir(rtl)) & { --button-transform-origin: right center; } padding: 0.5rem 1rem;
border-radius: 0; }}:global(dialog.settings-modal) { display: flex; flex-direction: column; inline-size: 48rem; block-size: 36rem;}main { flex: 1; overflow-y: auto; padding: 0.5rem 1rem; container-type: inline-size;}.control { display: flex;}.row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; min-block-size: 2rem;}.sub-group { padding-inline-start: 1rem;}footer { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.5rem 1rem; border-block-start: var(--border-subtle);}footer a { text-decoration: none;
&:hover { text-decoration: underline; }}.num { font-family: var(--font-family-monospace);}h3 { border-block-end: var(--border-text); padding-block-end: 0.25rem; margin-block-end: 0.5rem;}input,select,.control > :global(input) { max-inline-size: min(10rem, 40cqw);}</style>