feat: Settings uses the width of a wide screen: groups sit in two columns, each narrow enough that a row's control stays near its name (M503 #5595)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The content was capped at max-w-3xl, so a wide window showed one narrow strip and a lot of empty space. SettingsColumns lays a page's groups out as one column on a phone or narrow window and two side by side from xl up; the page cap moves to 100rem so the columns don't stretch on a very wide screen. - Account: Android app, Profile and Password, with Signed-in devices beside. - Playback: "Between tracks" (crossfade) beside "Volume leveling". - Connections: ListenBrainz beside Subsonic apps. - Appearance, Notifications, About: one column's width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// A settings page's groups laid out for the width it has (#5595 follow-up):
|
||||
// one column on a phone or a narrow window, two side by side on a wide
|
||||
// screen, so a wide window shows more at once instead of one narrow strip
|
||||
// with empty space beside it. Each column stays narrow enough that a row's
|
||||
// control sits near its name. `main` holds the groups read first; `side`
|
||||
// the ones beside them (signed-in devices beside the profile, say). A
|
||||
// page with only `main` keeps its groups to one column's width.
|
||||
|
||||
let { main, side }: { main: Snippet; side?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid items-start gap-6 xl:grid-cols-2">
|
||||
<div class="min-w-0 space-y-6">
|
||||
{@render main()}
|
||||
</div>
|
||||
{#if side}
|
||||
<div class="min-w-0 space-y-6">
|
||||
{@render side()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import SettingsColumns from './SettingsColumns.svelte';
|
||||
|
||||
const snip = (id: string) => createRawSnippet(() => ({ render: () => `<section data-testid="${id}"></section>` }));
|
||||
|
||||
describe('SettingsColumns', () => {
|
||||
test('main and side are two columns that sit side by side on a wide screen', () => {
|
||||
render(SettingsColumns, { main: snip('main'), side: snip('side') });
|
||||
const grid = screen.getByTestId('main').parentElement!.parentElement!;
|
||||
expect(grid.className).toMatch(/xl:grid-cols-2/);
|
||||
expect(grid.children).toHaveLength(2);
|
||||
expect(screen.getByTestId('side').parentElement!.parentElement).toBe(grid);
|
||||
});
|
||||
|
||||
test('with only main, the groups keep to one column', () => {
|
||||
render(SettingsColumns, { main: snip('only') });
|
||||
expect(screen.getByTestId('only').parentElement!.parentElement!.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,10 @@
|
||||
<SectionRail label="Settings sections" {items} />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Capped so a row's control sits near its name on a wide screen. -->
|
||||
<div class="min-w-0 max-w-3xl space-y-6">
|
||||
<!-- Each page lays its groups out in SettingsColumns: two columns on a
|
||||
wide screen, each narrow enough that a row's control sits near its
|
||||
name. The cap only stops the columns stretching on a very wide one. -->
|
||||
<div class="min-w-0 max-w-[100rem] space-y-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { user } from '#lib/auth/user.svelte.js';
|
||||
import ServerVersion from '#lib/components/ServerVersion.svelte';
|
||||
@@ -8,13 +9,17 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · About')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="About">
|
||||
<SettingRow label="Server version">
|
||||
<ServerVersion />
|
||||
</SettingRow>
|
||||
{#if user.value}
|
||||
<SettingRow label="Signed in as">
|
||||
<span class="text-sm text-text-secondary">{user.value.username}</span>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="About">
|
||||
<SettingRow label="Server version">
|
||||
<ServerVersion />
|
||||
</SettingRow>
|
||||
{#if user.value}
|
||||
<SettingRow label="Signed in as">
|
||||
<span class="text-sm text-text-secondary">{user.value.username}</span>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
@@ -103,89 +104,94 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Account')}</title></svelte:head>
|
||||
|
||||
<MobileAppDownload />
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<MobileAppDownload />
|
||||
|
||||
<form onsubmit={onSaveProfile}>
|
||||
<SettingsGroup title="Profile" id="profile">
|
||||
{#if loadFailed}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">
|
||||
Couldn't load your profile.
|
||||
<button type="button" class="underline hover:text-text-primary" onclick={loadProfile}>Try again</button>
|
||||
</div>
|
||||
{:else}
|
||||
<SettingRow label="Username">
|
||||
<span class="text-sm text-text-secondary">{stored?.username ?? '…'}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Display name" hint="Shown in place of your username." for="profile-displayname">
|
||||
<input
|
||||
id="profile-displayname"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
disabled={!stored}
|
||||
bind:value={displayName}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Email" hint="For password resets and email notifications." for="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
disabled={!stored}
|
||||
bind:value={email}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={!dirty || profileSaving} class={primary}>
|
||||
{profileSaving ? 'Saving…' : 'Save profile'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
<form onsubmit={onSaveProfile}>
|
||||
<SettingsGroup title="Profile" id="profile">
|
||||
{#if loadFailed}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">
|
||||
Couldn't load your profile.
|
||||
<button type="button" class="underline hover:text-text-primary" onclick={loadProfile}>Try again</button>
|
||||
</div>
|
||||
{:else}
|
||||
<SettingRow label="Username">
|
||||
<span class="text-sm text-text-secondary">{stored?.username ?? '…'}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Display name" hint="Shown in place of your username." for="profile-displayname">
|
||||
<input
|
||||
id="profile-displayname"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
disabled={!stored}
|
||||
bind:value={displayName}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Email" hint="For password resets and email notifications." for="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
disabled={!stored}
|
||||
bind:value={email}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={!dirty || profileSaving} class={primary}>
|
||||
{profileSaving ? 'Saving…' : 'Save profile'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
|
||||
<form onsubmit={onSavePassword}>
|
||||
<SettingsGroup title="Password" hint="Changing it signs out your other devices.">
|
||||
<SettingRow label="Current password" for="password-current">
|
||||
<input
|
||||
id="password-current"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="current-password"
|
||||
bind:value={current}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="New password" hint="At least 8 characters." for="password-new">
|
||||
<input
|
||||
id="password-new"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={next}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Confirm new password" for="password-confirm">
|
||||
<input
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={confirm}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={passwordSaving || !current || !next || !confirm} class={primary}>
|
||||
{passwordSaving ? 'Saving…' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
|
||||
<ActiveSessions />
|
||||
<form onsubmit={onSavePassword}>
|
||||
<SettingsGroup title="Password" hint="Changing it signs out your other devices.">
|
||||
<SettingRow label="Current password" for="password-current">
|
||||
<input
|
||||
id="password-current"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="current-password"
|
||||
bind:value={current}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="New password" hint="At least 8 characters." for="password-new">
|
||||
<input
|
||||
id="password-new"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={next}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Confirm new password" for="password-confirm">
|
||||
<input
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={confirm}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={passwordSaving || !current || !next || !confirm} class={primary}>
|
||||
{passwordSaving ? 'Saving…' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<ActiveSessions />
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { theme, setTheme, type ThemePreference } from '#lib/stores/theme.svelte.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
@@ -14,8 +15,12 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Appearance')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="Appearance">
|
||||
<SettingRow label="Theme" hint="System follows your device's light or dark setting.">
|
||||
<Segmented name="theme" label="Theme" options={themes} value={theme.value} onchange={setTheme} />
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="Appearance">
|
||||
<SettingRow label="Theme" hint="System follows your device's light or dark setting.">
|
||||
<Segmented name="theme" label="Theme" options={themes} value={theme.value} onchange={setTheme} />
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { CreateQueryResult } from '@tanstack/svelte-query';
|
||||
@@ -170,91 +171,96 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<SettingsGroup title="ListenBrainz" hint="Sends what you play to your ListenBrainz profile.">
|
||||
{#if $status.isPending}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Loading…</div>
|
||||
{:else if $status.isError}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Couldn't load ListenBrainz status.</div>
|
||||
{:else if $status.data}
|
||||
{@const s = $status.data}
|
||||
<SettingRow label="Status">
|
||||
<span class="text-sm {s.token_set && s.enabled ? 'text-text-primary' : 'text-text-secondary'}" data-testid="lb-state"
|
||||
>{lbState(s)}</span
|
||||
>
|
||||
</SettingRow>
|
||||
<SettingRow label="User token" hint={s.token_set ? undefined : lbHint} for={s.token_set ? undefined : 'lb-token'}>
|
||||
{#if s.token_set}
|
||||
<span class="text-sm text-text-secondary">Saved</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => $tokenMutation.mutate('')}
|
||||
disabled={$tokenMutation.isPending}
|
||||
class={button}>Remove</button
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
id="lb-token"
|
||||
type="password"
|
||||
bind:value={tokenInput}
|
||||
placeholder="Paste your token"
|
||||
class="w-56 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveToken}
|
||||
disabled={!tokenInput.trim() || $tokenMutation.isPending}
|
||||
class={button}>Save</button
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="ListenBrainz" hint="Sends what you play to your ListenBrainz profile.">
|
||||
{#if $status.isPending}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Loading…</div>
|
||||
{:else if $status.isError}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Couldn't load ListenBrainz status.</div>
|
||||
{:else if $status.data}
|
||||
{@const s = $status.data}
|
||||
<SettingRow label="Status">
|
||||
<span class="text-sm {s.token_set && s.enabled ? 'text-text-primary' : 'text-text-secondary'}" data-testid="lb-state"
|
||||
>{lbState(s)}</span
|
||||
>
|
||||
</SettingRow>
|
||||
<SettingRow label="User token" hint={s.token_set ? undefined : lbHint} for={s.token_set ? undefined : 'lb-token'}>
|
||||
{#if s.token_set}
|
||||
<span class="text-sm text-text-secondary">Saved</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => $tokenMutation.mutate('')}
|
||||
disabled={$tokenMutation.isPending}
|
||||
class={button}>Remove</button
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
id="lb-token"
|
||||
type="password"
|
||||
bind:value={tokenInput}
|
||||
placeholder="Paste your token"
|
||||
class="w-56 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveToken}
|
||||
disabled={!tokenInput.trim() || $tokenMutation.isPending}
|
||||
class={button}>Save</button
|
||||
>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Send my plays"
|
||||
hint={s.token_set ? undefined : 'Save a token first.'}
|
||||
for="lb-enabled"
|
||||
>
|
||||
<input
|
||||
id="lb-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={s.enabled}
|
||||
checked={s.enabled}
|
||||
disabled={!s.token_set || $enabledMutation.isPending}
|
||||
onchange={() => $enabledMutation.mutate(!s.enabled)}
|
||||
class="h-4 w-4 cursor-pointer accent-accent disabled:cursor-not-allowed"
|
||||
/>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Send my plays"
|
||||
hint={s.token_set ? undefined : 'Save a token first.'}
|
||||
for="lb-enabled"
|
||||
>
|
||||
<input
|
||||
id="lb-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={s.enabled}
|
||||
checked={s.enabled}
|
||||
disabled={!s.token_set || $enabledMutation.isPending}
|
||||
onchange={() => $enabledMutation.mutate(!s.enabled)}
|
||||
class="h-4 w-4 cursor-pointer accent-accent disabled:cursor-not-allowed"
|
||||
/>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Subsonic apps" hint="How third-party Subsonic apps sign in as you.">
|
||||
<SettingRow label="API token" hint="For apps that sign in with an API key. Use this where you can.">
|
||||
<button type="button" disabled={tokenSaving} onclick={onRegenerateToken} class={button}>
|
||||
{confirmRegen ? 'Click again to confirm' : tokenSaving ? 'Regenerating…' : 'Regenerate'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
{#if apiToken}
|
||||
{@render secret(apiToken, 'API token')}
|
||||
{/if}
|
||||
|
||||
<SettingRow
|
||||
label="Subsonic password"
|
||||
hint="For apps that ask for a username and password. Generated, never your login password."
|
||||
>
|
||||
{#if subsonicEnabled !== null}
|
||||
<span class="text-sm text-text-secondary" data-testid="subsonic-state">{subsonicEnabled ? 'On' : 'Off'}</span>
|
||||
{/if}
|
||||
<span class="flex gap-2" data-testid="subsonic-actions">
|
||||
<button type="button" disabled={subsonicSaving} onclick={onGenerateSubsonic} class={button}>
|
||||
{confirmSubsonic === 'generate' ? 'Click again to confirm' : subsonicEnabled ? 'Regenerate' : 'Generate'}
|
||||
</button>
|
||||
{#if subsonicEnabled}
|
||||
<button type="button" disabled={subsonicSaving} onclick={onClearSubsonic} class={button}>
|
||||
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<SettingsGroup title="Subsonic apps" hint="How third-party Subsonic apps sign in as you.">
|
||||
<SettingRow label="API token" hint="For apps that sign in with an API key. Use this where you can.">
|
||||
<button type="button" disabled={tokenSaving} onclick={onRegenerateToken} class={button}>
|
||||
{confirmRegen ? 'Click again to confirm' : tokenSaving ? 'Regenerating…' : 'Regenerate'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
{#if apiToken}
|
||||
{@render secret(apiToken, 'API token')}
|
||||
{/if}
|
||||
</span>
|
||||
</SettingRow>
|
||||
{#if subsonicPassword}
|
||||
{@render secret(subsonicPassword, 'Subsonic password')}
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingRow
|
||||
label="Subsonic password"
|
||||
hint="For apps that ask for a username and password. Generated, never your login password."
|
||||
>
|
||||
{#if subsonicEnabled !== null}
|
||||
<span class="text-sm text-text-secondary" data-testid="subsonic-state">{subsonicEnabled ? 'On' : 'Off'}</span>
|
||||
{/if}
|
||||
<span class="flex gap-2" data-testid="subsonic-actions">
|
||||
<button type="button" disabled={subsonicSaving} onclick={onGenerateSubsonic} class={button}>
|
||||
{confirmSubsonic === 'generate' ? 'Click again to confirm' : subsonicEnabled ? 'Regenerate' : 'Generate'}
|
||||
</button>
|
||||
{#if subsonicEnabled}
|
||||
<button type="button" disabled={subsonicSaving} onclick={onClearSubsonic} class={button}>
|
||||
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</SettingRow>
|
||||
{#if subsonicPassword}
|
||||
{@render secret(subsonicPassword, 'Subsonic password')}
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import NotificationSettings from '#lib/components/NotificationSettings.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Notifications')}</title></svelte:head>
|
||||
|
||||
<NotificationSettings />
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<NotificationSettings />
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { player, setCrossfade } from '#lib/player/store.svelte.js';
|
||||
import NormalizationSetting from '#lib/components/NormalizationSetting.svelte';
|
||||
@@ -8,25 +9,33 @@
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Playback')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="Playback">
|
||||
<SettingRow
|
||||
label="Crossfade"
|
||||
hint="Fades one track into the next. Most albums sound best with it off."
|
||||
for="crossfade-slider"
|
||||
>
|
||||
<input
|
||||
id="crossfade-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={player.crossfadeSec}
|
||||
oninput={(e) => setCrossfade(Number((e.currentTarget as HTMLInputElement).value))}
|
||||
class="w-40 accent-accent"
|
||||
/>
|
||||
<span class="w-8 text-right text-sm tabular-nums text-text-primary">
|
||||
{player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<NormalizationSetting />
|
||||
</SettingsGroup>
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="Between tracks">
|
||||
<SettingRow
|
||||
label="Crossfade"
|
||||
hint="Fades one track into the next. Most albums sound best with it off."
|
||||
for="crossfade-slider"
|
||||
>
|
||||
<input
|
||||
id="crossfade-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={player.crossfadeSec}
|
||||
oninput={(e) => setCrossfade(Number((e.currentTarget as HTMLInputElement).value))}
|
||||
class="w-40 accent-accent"
|
||||
/>
|
||||
<span class="w-8 text-right text-sm tabular-nums text-text-primary">
|
||||
{player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`}
|
||||
</span>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<SettingsGroup title="Volume leveling" hint="Evens out loudness between tracks and albums.">
|
||||
<NormalizationSetting />
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
|
||||
Reference in New Issue
Block a user