forked from Keelrleek/SoulStream
settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text
This commit is contained in:
1 parent
7e291cfccc
commit
07ac3d3631
2 files changed
+167
-49
No files matched your search
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
// A chooser: one value out of a list, in the app's own menu vocabulary
|
||||
// (Library's sort menu ← CatalogDiscography ← the player bar's popover)
|
||||
// instead of a native <select>, which WebView2 draws with the OS's text
|
||||
// rendering and looks wrong beside everything else. The trigger is a
|
||||
// pill with the current label and a chevron; the popover is the same
|
||||
// dark .menu as the sort menu, with the accent check on the current row.
|
||||
//
|
||||
// Portalled to body and positioned fixed: a settings card clips its
|
||||
// overflow, and the pane is a containing block for anything fixed
|
||||
// inside it. Closes on a choice, Escape, an outside press, a scroll, or
|
||||
// a resize — a menu that drifts from its trigger is worse than none.
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
/** @type {{ options: [string, string][], value: string, onchange: (v: string) => void, disabled?: boolean, title?: string, width?: number }} */
|
||||
let { options, value, onchange, disabled = false, title = "", width = 0 } = $props();
|
||||
|
||||
let triggerEl = $state(null);
|
||||
let menuEl = $state(null);
|
||||
let open = $state(false);
|
||||
let pos = $state({ top: 0, left: 0, minWidth: 186 });
|
||||
|
||||
const label = $derived(options.find(([v]) => v === value)?.[1] ?? value ?? "");
|
||||
|
||||
function portal(node) {
|
||||
document.body.appendChild(node);
|
||||
return { destroy: () => node.remove() };
|
||||
}
|
||||
|
||||
function place() {
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
const minWidth = Math.max(186, width || r.width);
|
||||
const est = 12 + options.length * 30;
|
||||
let top = r.bottom + 6;
|
||||
if (top + est > window.innerHeight - 8) top = Math.max(8, r.top - 6 - est);
|
||||
let left = Math.min(r.right - minWidth, window.innerWidth - minWidth - 8);
|
||||
left = Math.max(8, left);
|
||||
pos = { top, left, minWidth };
|
||||
}
|
||||
|
||||
function toggle(e) {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
if (open) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
place();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function pick(v) {
|
||||
open = false;
|
||||
if (v !== value) onchange(v);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e) => {
|
||||
if (triggerEl?.contains(e.target) || menuEl?.contains(e.target)) return;
|
||||
open = false;
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") open = false;
|
||||
};
|
||||
const close = () => (open = false);
|
||||
document.addEventListener("pointerdown", onDown, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<button bind:this={triggerEl} class="chooser" class:on={open} onclick={toggle} {disabled} {title} aria-haspopup="menu" aria-expanded={open}>
|
||||
<span class="lab">{label}</span>
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="menu" role="menu" use:portal bind:this={menuEl} style:top="{pos.top}px" style:left="{pos.left}px" style:min-width="{pos.minWidth}px">
|
||||
{#each options as [v, l] (v)}
|
||||
<button role="menuitemradio" aria-checked={v === value} class:cur={v === value} onclick={() => pick(v)}>
|
||||
{l}
|
||||
{#if v === value}<span class="mcheck"><Icon name="check" size={13} /></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chooser {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 11px 0 14px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.chooser .lab { overflow: hidden; text-overflow: ellipsis; }
|
||||
.chooser :global(svg) { color: var(--text-secondary); flex: none; transition: transform 160ms ease; }
|
||||
.chooser:hover:not(:disabled), .chooser.on { background: var(--bg-press); }
|
||||
.chooser.on :global(svg) { transform: rotate(180deg); }
|
||||
.chooser:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* the sort menu's popover, verbatim — solid on purpose: a menu has to be
|
||||
readable over whatever it lands on, dark glass included */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px;
|
||||
border-radius: 9px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
|
||||
font-family: var(--app-font);
|
||||
}
|
||||
.menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu button:hover, .menu button.cur { color: var(--text-primary); }
|
||||
.menu button:hover { background: var(--bg-highlight); }
|
||||
.menu .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
|
||||
</style>
|
||||
+18
-49
@@ -10,6 +10,7 @@
|
||||
import { applyGlass } from "../lib/glass.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Chooser from "../components/Chooser.svelte";
|
||||
import {
|
||||
community,
|
||||
refreshFreenet,
|
||||
@@ -311,6 +312,19 @@
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ---- the long lists, as [value, label] for the chooser -------------------
|
||||
const RETENTION = [["1", "1 day"], ["3", "3 days"], ["7", "1 week"], ["14", "2 weeks"], ["30", "1 month (recommended)"], ["90", "3 months"], ["forever", "Forever — save to library"]];
|
||||
const CACHE = [["512", "512 MB"], ["1024", "1 GB"], ["2048", "2 GB (recommended)"], ["5120", "5 GB"], ["10240", "10 GB"], ["20480", "20 GB"], ["0", "Unlimited"]];
|
||||
const MIN_QUALITY = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
|
||||
const audioOutputs = $derived.by(() => {
|
||||
if (!settings) return [];
|
||||
const out = [["", "System default (recommended)"]];
|
||||
const cur = settings.audio_output_device;
|
||||
if (cur && !settings.audio_output_devices.includes(cur)) out.push([cur, `${cur} (unavailable)`]);
|
||||
for (const d of settings.audio_output_devices) out.push([d, d]);
|
||||
return out;
|
||||
});
|
||||
|
||||
// ---- the index ---------------------------------------------------------
|
||||
const SECTIONS = [
|
||||
{ id: "account", title: "Account", group: "You" },
|
||||
@@ -533,17 +547,7 @@
|
||||
<div class="name">Audio output</div>
|
||||
<div class="hint">System default follows Windows when headphones or speakers change.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<select class="menu" value={settings.audio_output_device ?? ""} disabled={saving} onchange={(e) => setAudioOutput(e.currentTarget.value)}>
|
||||
<option value="">System default (recommended)</option>
|
||||
{#if settings.audio_output_device && !settings.audio_output_devices.includes(settings.audio_output_device)}
|
||||
<option value={settings.audio_output_device}>{settings.audio_output_device} (unavailable)</option>
|
||||
{/if}
|
||||
{#each settings.audio_output_devices as device (device)}
|
||||
<option value={device}>{device}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl"><Chooser options={audioOutputs} value={settings.audio_output_device ?? ""} onchange={(v) => setAudioOutput(v)} disabled={saving} /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
@@ -611,34 +615,14 @@
|
||||
<div class="name">Keep streamed tracks</div>
|
||||
<div class="hint">Streamed songs stay in a private cache for instant replay — not in your library. Forever adds everything you stream to the library instead.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<select class="menu" value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)} disabled={saving} onchange={(e) => setStreamRetention(e.currentTarget.value)}>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7">1 week</option>
|
||||
<option value="14">2 weeks</option>
|
||||
<option value="30">1 month (recommended)</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="forever">Forever — save to library</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl"><Chooser options={RETENTION} value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)} onchange={(v) => setStreamRetention(v)} disabled={saving} /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Cache size limit</div>
|
||||
<div class="hint">Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<select class="menu" value={String(settings.stream_cache_max_mb)} disabled={saving} onchange={(e) => setStreamCacheMax(e.currentTarget.value)}>
|
||||
<option value="512">512 MB</option>
|
||||
<option value="1024">1 GB</option>
|
||||
<option value="2048">2 GB (recommended)</option>
|
||||
<option value="5120">5 GB</option>
|
||||
<option value="10240">10 GB</option>
|
||||
<option value="20480">20 GB</option>
|
||||
<option value="0">Unlimited</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl"><Chooser options={CACHE} value={String(settings.stream_cache_max_mb)} onchange={(v) => setStreamCacheMax(v)} disabled={saving} /></div>
|
||||
</div>
|
||||
<div class="foot">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</div>
|
||||
</div>
|
||||
@@ -721,15 +705,7 @@
|
||||
<div class="name">Minimum</div>
|
||||
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<select class="menu" value={sourcePrefsFor(mode.key).min} disabled={saving} onchange={(e) => setSourcePref(mode.key, "min", e.currentTarget.value)}>
|
||||
<option value="any">Any quality</option>
|
||||
<option value="kbps192">192 kbps and up</option>
|
||||
<option value="kbps256">256 kbps and up</option>
|
||||
<option value="kbps320">320 kbps and up</option>
|
||||
<option value="lossless">Lossless only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ctl"><Chooser options={MIN_QUALITY} value={sourcePrefsFor(mode.key).min} onchange={(v) => setSourcePref(mode.key, "min", v)} disabled={saving} /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
@@ -876,13 +852,6 @@
|
||||
.seg button.on { background: var(--accent); color: #000; }
|
||||
.seg button:hover:not(.on):not(:disabled) { color: #fff; }
|
||||
.seg button:disabled { opacity: 0.6; cursor: default; }
|
||||
/* a long list stays a real <select>, dressed as the menu pill */
|
||||
.menu { appearance: none; -webkit-appearance: none; height: 32px; padding: 0 30px 0 14px; border-radius: 99px; border: none; background-color: var(--surf-hi); color: #fff; font-size: 12.5px; font-weight: 800; outline: none; cursor: pointer; max-width: 320px;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
|
||||
background-position: calc(100% - 16px) 55%, calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.menu:hover { background-color: var(--bg-press); }
|
||||
.menu:disabled { opacity: 0.6; cursor: default; }
|
||||
.menu option { background: var(--bg-elevated); color: var(--text-primary); font-weight: 600; }
|
||||
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
|
||||
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||||
.ax-ghost.sm { height: 28px; padding: 0 11px; font-size: 11px; }
|
||||
|
||||
Reference in new issue
Block a user