#56 named 44px and #195 took the page header there. Settings is the other half of #186 and much the larger one: swept on the reference device (TLP301, 424x439) with all eleven config-sections expanded, **120 controls** were under the floor -- not the 93 the issue's table implies, and config-field is eight of them. The bulk is behind the disclosures, which is why nobody had counted it: 36 .column-arrow-btn 16x14 <- smallest in the app 29 .column-toggle 16x16 26 shortcut-capture button 80x25 8 download format checkbox 16x16 7 config-field select 335x30 6 wa-input / wa-button 204x20, 185x21 **The density argument, measured rather than guessed, and it is smaller than it looks.** The rows were already near the floor -- .column-item is 335x36 and .shortcut-row 335x37; it is the controls *inside* them that were 14-25px. So a control grows into the row it already occupies and the row goes 36 to 44. Measured after: the two column lists went 373->447 and 690->850, +234px over the whole page. Half a screen of extra scroll on a page that already scrolls, against 36 targets of 16x14. **Settings is cheaper than the header was, and for a stated reason.** There is no overflow fit on this page, so the header's "only width is contested" rule does not bind at all and nothing here needs padding with a negative margin. Height is a min-size, and the two square controls can simply be square. Three shapes, because one rule does not fit three kinds of control: **A native checkbox is targeted through its label.** It cannot grow its hit area without growing its paint, and a 44px checkbox is not what anyone wants -- so .column-label is a real <label for> now and the column's *name* is the target, 70x44 rather than 16x16. That is the argument config-field already makes one file over ("a real label association also makes the label text a click target, which is behaviour, not annotation"), and here it is the whole fix. The download formats already had the label; they only needed the height. **The arrows take padding, which is invisible.** They carry background: none and a transparent border, so 16x14 -> 44x44 changes nothing anyone can see until hover -- #186's Direction exactly. **Web Awesome's controls come from the library's own API.** Their height is decided inside somebody else's shadow root, and --wa-form-control-height is the variable that decides it. A custom property inherits through a shadow boundary, so a :host declaration reaches them; styles/wa-touch-floor.css.ts is that, once, adopted rather than written at :root in index.css -- a :root rule would be invisible to the component tier, which renders a component and no page stylesheet. **Two controls no sweep can see are fixed by name**, and they are the trap this issue keeps setting. config-field's toggle has an <input> that is opacity: 0; width: 0; height: 0, so a walk of every input skips it as a zero-sized node -- what a finger hits is the <label>, which measured **34x19**, smaller than anything in either of #186's tables and absent from both. It is 44x44 with the pill still painted at 2.5em x 1.4em and negative inline margins keeping it flush with the inputs above. And shortcut-capture's reset button renders only for a shortcut somebody has rebound, so a sweep of a fresh install never meets it. Verified on the device, same method as the sweep that filed it: 120 controls under the floor before, 42 after. All 42 are accounted for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four are wa-input's inner input at 204x**42**, which is the control measured *inside* its own 1px border (part=base is 238x44), and one is the skip link, which #186 already ruled out as keyboard-only. The e2e suite passes, top-bar-fit and header-action-overflow included -- but that is **chromium**, which is half an answer, and saying so is the whole of what #195's second commit was about. What can be argued rather than run: library-filter is the only thing here in a container that measures itself, and its width did not change. The fit measures inline size. Two page-header screenshots are refreshed because they are this issue's own debris -- #195's taller sort control, merged last session, with its references never re-recorded. app-sidebar's and now-playing's are deliberately left: they are unrelated drift, and blessing an unrelated screenshot is how the sidebar reference came to still list a destination #27 retired. That is #196.
2635 lines
92 KiB
TypeScript
2635 lines
92 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, state } from 'lit/decorators.js';
|
|
import { repeat } from 'lit/directives/repeat.js';
|
|
import { EventsOn } from '@runtime/runtime';
|
|
import type * as explore from '@go/explore/models.js';
|
|
import {
|
|
AddLibrary,
|
|
RenameLibrary,
|
|
RemoveLibrary,
|
|
GetRemovalImpact,
|
|
GetAllLibrariesWithTrackCounts,
|
|
ScanLibrary,
|
|
ScanAllLibraries,
|
|
FullRescan,
|
|
} from '@go/library/library.js';
|
|
import { jobStore } from '@store/job-store';
|
|
import type { Job } from '@store/job-store';
|
|
import '@components/jobs/job-panel';
|
|
import {
|
|
GetScanConcurrency,
|
|
SetScanConcurrency,
|
|
GetDefaultPage,
|
|
SetDefaultPage,
|
|
GetQueueFallback,
|
|
SetQueueFallback,
|
|
GetAllowMeteredCatalogDownload,
|
|
SetAllowMeteredCatalogDownload,
|
|
GetPopupVolume,
|
|
SetPopupVolume,
|
|
} from '@go/config/config.js';
|
|
import { GetIndexStatus } from '@go/explore/service.js';
|
|
import { notificationStore } from '@store/notification-store';
|
|
import { describeError, explainError } from '@utils/describe-error';
|
|
import type * as library from '@go/library/models.js';
|
|
import { ThemeController } from '@store/controllers/theme-controller';
|
|
import { TrackListController } from '@store/controllers/tracklist-controller';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
|
import { VIEW_META } from '../../services/view-meta';
|
|
import { downloadStore } from '@store/download-store';
|
|
import { GetAllPlaylists } from '@go/playlist/service.js';
|
|
import type * as playlist from '@go/playlist/models.js';
|
|
import { Events } from '../../events';
|
|
import {
|
|
SHORTCUT_CATEGORIES,
|
|
SHORTCUT_META,
|
|
} from '../../services/shortcut-meta';
|
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
|
import type { ConfigFieldChangeEvent } from './config-field';
|
|
import type { BackgroundShade } from '@store/theme-store';
|
|
import type { IconStyle } from '@store/favorites-store';
|
|
import {
|
|
COLUMN_DEFS,
|
|
ALL_COLUMN_IDS,
|
|
} from '@components/track-list/columns';
|
|
|
|
import './config-field';
|
|
import './config-section';
|
|
// The view filter's home (#148). The same component the top bar
|
|
// carries, placed a second time rather than reimplemented -- two
|
|
// definitions of "which library am I browsing" is what this is for.
|
|
import '@components/library-filter/library-filter';
|
|
import './download-clients';
|
|
import './shortcut-capture';
|
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
|
import { shortcutsStore } from '../../store/shortcuts-store';
|
|
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
|
import { list } from '@utils/binding';
|
|
import { pickDirectory } from '../../utils/pick-directory';
|
|
|
|
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
|
|
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
|
|
|
// ===================================================================
|
|
// Config page component
|
|
// ===================================================================
|
|
|
|
@customElement('config-page')
|
|
export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|
// --- Theme controller for reading/writing theme state ---
|
|
private themeCtrl = new ThemeController(this);
|
|
|
|
// --- Track-list column config controller ---
|
|
private trackListCtrl = new TrackListController(this);
|
|
|
|
// --- Favorites controller ---
|
|
private favCtrl = new FavoritesController(this);
|
|
|
|
/** Which destinations the navigation offers (#25). */
|
|
private viewsCtrl = new ViewVisibilityController(this);
|
|
|
|
/**
|
|
* The job snapshot, for the per-library scan status (#27).
|
|
*
|
|
* Held as state rather than read from the store in `render()` so
|
|
* Lit sees the dependency: the store notifies, and a getter read
|
|
* inside a template is not a reactive input.
|
|
*/
|
|
@state() private jobs: Job[] = [];
|
|
|
|
/**
|
|
* Set between pressing a scan button and the job snapshot that
|
|
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
|
|
* is long enough for a second click to start a second scan.
|
|
*/
|
|
@state() private startingScan = false;
|
|
|
|
private unsubscribeJobs: (() => void) | null = null;
|
|
|
|
// --- Shortcuts controller ---
|
|
private shortcutsCtrl = new ShortcutsController(this);
|
|
|
|
// --- Now Playing state ---
|
|
@state() private scrollMode = 'hover';
|
|
|
|
/** Whether the ~0.6 GB catalog may be fetched on mobile data. */
|
|
@state() private allowMeteredCatalogDownload = false;
|
|
|
|
// --- Favorites state ---
|
|
@state() private playlists: playlist.Summary[] = [];
|
|
|
|
// --- Library state ---
|
|
@state() private libraries: library.Info[] = [];
|
|
@state() private editingLibraryId: number | null = null;
|
|
@state() private editingName = '';
|
|
/** The library a removal is in flight for. The impact is computed
|
|
* before the confirmation rather than held here, so this is now
|
|
* "which row is busy" and nothing else. */
|
|
@state() private removingLibraryId: number | null = null;
|
|
@state() private activeMenuId: number | null = null;
|
|
@state() private concurrencyMode = 'auto';
|
|
@state() private defaultPage = 'home';
|
|
@state() private queueFallback = 'favorites';
|
|
|
|
@state() private popupVolume = false;
|
|
@state() private indexStatus: explore.IndexStatus | null = null;
|
|
/** Three states, not one: the panel used to say "Loading status…"
|
|
* for the entire session, because the only thing that ever set
|
|
* `indexStatus` was an event that fires on *change* (errors.M3). */
|
|
@state() private indexStatusFailed = false;
|
|
@state() private shortcutConflict: {
|
|
newAction: string;
|
|
newKey: string;
|
|
existingAction: string;
|
|
} | null = null;
|
|
|
|
private cancelIndexStatus?: () => void;
|
|
private cancelLibraryAdded?: () => void;
|
|
private cancelLibraryRenamed?: () => void;
|
|
private cancelLibraryRemoved?: () => void;
|
|
|
|
static override styles = css`
|
|
:host {
|
|
display: block;
|
|
padding: 1.5em;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
color: var(--yj-text-primary, #fff);
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0 0 1em;
|
|
font-size: 1.4em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Button styles */
|
|
button {
|
|
padding: 0.5em 1.25em;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease;
|
|
white-space: nowrap;
|
|
/* The app's 44px touch floor (#56, #186), stated once for
|
|
all 41 buttons this page renders rather than per class.
|
|
Height is free here: Settings has no overflow fit, so
|
|
the header's "only width is contested" rule does not
|
|
bind, and the two classes that need more than a height
|
|
say so below. */
|
|
min-block-size: 44px;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-warning {
|
|
background: var(--yj-warning, #e8590c);
|
|
color: var(--yj-warning-fg, #fff);
|
|
}
|
|
|
|
.btn-warning:hover:not(:disabled) {
|
|
background: var(--yj-warning-hover, #d9480f);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--yj-error, #e03131);
|
|
color: var(--yj-error-fg, #fff);
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
background: var(--yj-error-hover, #c92a2a);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--yj-success, #2f9e44);
|
|
color: var(--yj-success-fg, #fff);
|
|
}
|
|
|
|
.btn-success:hover:not(:disabled) {
|
|
background: var(--yj-success-hover, #2b8a3e);
|
|
}
|
|
|
|
.btn-ghost {
|
|
background: transparent;
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
padding: 0.3em 0.75em;
|
|
font-size: 0.75em;
|
|
border: 1px solid var(--yj-border, #444);
|
|
}
|
|
|
|
.btn-ghost:hover:not(:disabled) {
|
|
background: var(--yj-bg-overlay, #495057);
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.btn-ghost.copied {
|
|
border-color: var(--yj-success, #2f9e44);
|
|
color: var(--yj-success-text, #51cf66);
|
|
}
|
|
|
|
/* Scan actions */
|
|
.scan-actions {
|
|
display: flex;
|
|
gap: 0.75em;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* #148, and the second half of #57.
|
|
|
|
library-filter is the only control in the app that calls
|
|
setSelectedLibrary, and it lived in the top bar -- which
|
|
#57 takes out of the layout on a phone, and which #143
|
|
already refused to hide as a fit step precisely because
|
|
hiding it takes away an action. So the selection gets a home
|
|
that does not depend on that bar existing.
|
|
|
|
At every width, not below 600px: a phone-only copy would be
|
|
a second place the control lives, and "where do I change
|
|
which library I am browsing" having two answers by size is
|
|
the fault, not the fix. */
|
|
.library-scope {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1em;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
.library-scope .scope-label {
|
|
font-weight: 600;
|
|
font-size: 0.85em;
|
|
color: var(--yj-text-primary, #fff);
|
|
display: block;
|
|
}
|
|
|
|
.library-scope .scope-description {
|
|
font-size: 0.75em;
|
|
color: var(--yj-text-tertiary, #888);
|
|
margin: 0.35em 0 0;
|
|
max-width: 40em;
|
|
}
|
|
|
|
.save-row {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
margin-top: 0.5em;
|
|
}
|
|
|
|
/* Status bar */
|
|
.status-bar {
|
|
margin-top: 1em;
|
|
padding: 0.75em 1em;
|
|
background: var(--yj-bg-elevated, #343a40);
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
min-height: 1.2em;
|
|
}
|
|
|
|
.status-bar.active {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
/* Progress bar */
|
|
.progress-info {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.5em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.progress-label {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.progress-detail {
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
.progress-percent {
|
|
margin-left: auto;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.progress-phase {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.progress-track {
|
|
height: 6px;
|
|
background: var(--yj-bg-base, #1a1b1e);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: var(--yj-accent, #ffd43b);
|
|
border-radius: 3px;
|
|
transition: width 300ms ease;
|
|
}
|
|
|
|
/* Error block */
|
|
.error-block {
|
|
margin-top: 1em;
|
|
border: 1px solid var(--yj-error, #e03131);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.error-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5em 1em;
|
|
background: color-mix(
|
|
in srgb,
|
|
var(--yj-error, #e03131) 15%,
|
|
var(--yj-bg-elevated, #343a40)
|
|
);
|
|
}
|
|
|
|
.error-title {
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
color: var(--yj-error-text, #ff8787);
|
|
}
|
|
|
|
.error-body {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
padding: 0.75em 1em;
|
|
background: var(--yj-bg-elevated, #343a40);
|
|
}
|
|
|
|
.error-body pre {
|
|
margin: 0;
|
|
font-size: 0.8em;
|
|
font-family: inherit;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: var(--yj-text-secondary, #adb5bd);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* Metrics tree */
|
|
.metrics-wrapper {
|
|
margin-top: 1em;
|
|
}
|
|
|
|
.metrics-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.metrics-title {
|
|
margin: 0;
|
|
font-size: 0.95em;
|
|
font-weight: 600;
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
details {
|
|
margin-left: 1em;
|
|
}
|
|
|
|
details.root {
|
|
margin-left: 0;
|
|
}
|
|
|
|
summary {
|
|
cursor: pointer;
|
|
padding: 0.25em 0;
|
|
font-size: 0.85em;
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
list-style: none;
|
|
}
|
|
|
|
summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
summary::before {
|
|
content: '\\25B6';
|
|
display: inline-block;
|
|
width: 1em;
|
|
font-size: 0.6em;
|
|
vertical-align: middle;
|
|
transition: transform 0.15s ease;
|
|
margin-right: 0.35em;
|
|
}
|
|
|
|
details[open] > summary::before {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.metric-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.2em 0;
|
|
padding-left: 1.35em;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.metric-label {
|
|
color: var(--yj-text-secondary, #adb5bd);
|
|
}
|
|
|
|
.metric-value {
|
|
color: var(--yj-text-primary, #e9ecef);
|
|
font-family: monospace;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.metric-value.highlight {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.metric-note {
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
font-size: 0.75em;
|
|
font-style: italic;
|
|
padding-left: 1.35em;
|
|
}
|
|
|
|
.counts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, auto);
|
|
gap: 0.25em 1.5em;
|
|
padding-left: 1.35em;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.count-label {
|
|
color: var(--yj-text-secondary, #adb5bd);
|
|
}
|
|
|
|
.count-value {
|
|
color: var(--yj-text-primary, #e9ecef);
|
|
font-family: monospace;
|
|
}
|
|
|
|
/* Theme preview */
|
|
.color-preview {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
margin-top: 0.75em;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.swatch {
|
|
width: 2em;
|
|
height: 2em;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--yj-border, #444);
|
|
}
|
|
|
|
.swatch-label {
|
|
font-size: 0.7em;
|
|
text-align: center;
|
|
color: var(--yj-text-tertiary, #888);
|
|
margin-top: 0.2em;
|
|
}
|
|
|
|
.swatch-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Track list column configurator */
|
|
.column-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
/* The two column lists are the densest thing in the app, and
|
|
the density argument is why they are shaped the way they
|
|
are rather than simply grown (#186).
|
|
|
|
Measured on the reference device: the row was already
|
|
335x36 -- it is the controls *inside* it that were 16x16 and
|
|
**16x14**, the smallest anywhere in this app, 36 of them.
|
|
So the fix grows the controls into the row they already
|
|
occupy and only takes the row from 36 to 44, which over the
|
|
two lists (10 and 19 items) is 232px of extra scroll on a
|
|
439px screen. Growing each control to its own 44px row
|
|
instead would have cost four screens. */
|
|
.column-item {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0.5em;
|
|
padding: 0 0.75em;
|
|
min-block-size: 44px;
|
|
border-bottom: 1px solid
|
|
var(--yj-border-subtle, #333);
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.column-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.column-item.enabled {
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.column-item.disabled {
|
|
color: var(--yj-text-tertiary, #888);
|
|
}
|
|
|
|
/* A native checkbox cannot grow its hit area without growing
|
|
its paint, and a 44px checkbox is not what anyone wants. So
|
|
the target is the label instead: .column-label is a real
|
|
<label for> now, which makes the column's *name* the thing
|
|
you tap -- ~250x44 rather than 16x16.
|
|
|
|
That is the argument config-field already makes one file
|
|
over for its own labels: "a real label association also
|
|
makes the label text a click target for the control, which
|
|
is behaviour, not annotation". Here it is the whole fix. */
|
|
.column-toggle {
|
|
cursor: pointer;
|
|
align-self: center;
|
|
accent-color: var(
|
|
--yj-accent,
|
|
#ffd43b
|
|
);
|
|
}
|
|
|
|
.column-label {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
min-block-size: 44px;
|
|
}
|
|
|
|
.view-note {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-size: var(--yj-font-size-sm, 0.85rem);
|
|
margin-left: auto;
|
|
/* The row stretches its children so the label can be a
|
|
full-height target; this is text, not a target. */
|
|
align-self: center;
|
|
}
|
|
|
|
.column-arrows {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0.15em;
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* 16x14 before this, and they carry background: none and a
|
|
transparent border -- so padding out to 44px grows the
|
|
target and changes nothing anyone can see until hover,
|
|
which is precisely what #186's Direction asks for. */
|
|
.column-arrow-btn {
|
|
background: none;
|
|
border: 1px solid transparent;
|
|
border-radius: 3px;
|
|
color: var(--yj-text-tertiary, #888);
|
|
cursor: pointer;
|
|
font-size: 0.65em;
|
|
line-height: 1;
|
|
padding: 0.2em 0.35em;
|
|
min-inline-size: 44px;
|
|
min-block-size: 44px;
|
|
transition:
|
|
color 0.15s,
|
|
border-color 0.15s;
|
|
}
|
|
|
|
.column-arrow-btn:hover {
|
|
color: var(--yj-text-primary, #fff);
|
|
border-color: var(
|
|
--yj-border-subtle,
|
|
#333
|
|
);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(
|
|
--yj-accent,
|
|
#ffd43b
|
|
);
|
|
color: var(--yj-accent-fg, #000);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.status-bar.paused {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
/* Library management */
|
|
.library-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.library-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75em;
|
|
padding: 0.4em 0.5em;
|
|
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
|
font-size: var(--yj-text-sm, 0.8em);
|
|
color: var(--yj-text-muted, #999);
|
|
}
|
|
|
|
.library-header-label {
|
|
user-select: none;
|
|
}
|
|
|
|
.library-checkbox {
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.library-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5em 0.75em;
|
|
padding: 0.6em 0.5em;
|
|
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
|
}
|
|
|
|
.library-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.library-row:hover {
|
|
background: var(--yj-bg-elevated, #343a40);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.library-row .inline-progress {
|
|
flex-basis: 100%;
|
|
padding-left: 1.75em;
|
|
}
|
|
|
|
.library-row .inline-progress .progress-track {
|
|
margin-top: 0.25em;
|
|
}
|
|
|
|
.library-scan-status {
|
|
font-size: 0.75em;
|
|
font-weight: 400;
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.library-name {
|
|
flex: 1;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
font-weight: 500;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.library-name:hover {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.library-path {
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
font-size: var(--yj-text-sm, 13px);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 300px;
|
|
}
|
|
|
|
.library-count {
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
font-size: var(--yj-text-sm, 13px);
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.edit-input {
|
|
flex: 1;
|
|
padding: 0.3em 0.5em;
|
|
background: var(--yj-bg-elevated, #343a40);
|
|
border: 1px solid var(--yj-accent, #ffd43b);
|
|
border-radius: 4px;
|
|
color: var(--yj-text-primary, #fff);
|
|
font-size: 0.9em;
|
|
font-family: inherit;
|
|
outline: none;
|
|
}
|
|
|
|
.overflow-wrapper {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.overflow-btn {
|
|
cursor: pointer;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--yj-text-tertiary, #868e96);
|
|
font-size: 1.1em;
|
|
padding: 0.2em 0.4em;
|
|
letter-spacing: 2px;
|
|
border-radius: 4px;
|
|
/* Square, so it needs the width too -- the shared rule
|
|
above only gives it a height. It was 31x31, and it is
|
|
the only route to "Remove library", which is the case
|
|
#55 settled one component over: the way out is 44px. */
|
|
min-inline-size: 44px;
|
|
}
|
|
|
|
.overflow-btn:hover {
|
|
background: var(--yj-bg-overlay, #495057);
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.overflow-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
background: var(--yj-bg-surface, #2a2a2a);
|
|
border: 1px solid var(--yj-border, #444);
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
z-index: 100;
|
|
min-width: 120px;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.overflow-item {
|
|
padding: 0.5em 1em;
|
|
font-size: var(--yj-text-sm, 13px);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overflow-item:hover {
|
|
background: var(--yj-bg-elevated, #343a40);
|
|
}
|
|
|
|
.overflow-item--danger {
|
|
color: var(--yj-error-text, #ff8787);
|
|
}
|
|
|
|
.overflow-item--danger:hover {
|
|
background: color-mix(
|
|
in srgb,
|
|
var(--yj-error, #e03131) 10%,
|
|
var(--yj-bg-elevated, #343a40)
|
|
);
|
|
}
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: #fff;
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
vertical-align: middle;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Keyboard shortcuts section */
|
|
.shortcut-category {
|
|
margin-bottom: 16px;
|
|
}
|
|
.shortcut-category-header {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
font-weight: 600;
|
|
color: var(--yj-text-secondary, #aaa);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 8px;
|
|
padding-bottom: 4px;
|
|
border-bottom: 1px solid
|
|
var(--yj-border, #444);
|
|
}
|
|
.shortcut-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 0;
|
|
gap: 16px;
|
|
}
|
|
.shortcut-label {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
color: var(--yj-text-primary, #eee);
|
|
}
|
|
.shortcut-scope {
|
|
font-size: var(--yj-text-xs, 11px);
|
|
color: var(
|
|
--yj-text-tertiary,
|
|
#888
|
|
);
|
|
margin-left: 4px;
|
|
}
|
|
.shortcut-actions {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
.conflict-banner {
|
|
margin-top: 12px;
|
|
padding: 12px;
|
|
background: rgba(255, 165, 0, 0.1);
|
|
border: 1px solid
|
|
rgba(255, 165, 0, 0.4);
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.conflict-text {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
}
|
|
.conflict-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Search index status */
|
|
.index-status {
|
|
padding: 0 0.25em 0.5em;
|
|
}
|
|
|
|
.index-stats {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5em;
|
|
font-size: var(--yj-text-sm);
|
|
color: var(--yj-text-secondary, #aaa);
|
|
margin-bottom: 1em;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.index-stat-sep {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.index-tiers {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5em;
|
|
}
|
|
|
|
.index-tier {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6em;
|
|
font-size: var(--yj-text-sm);
|
|
}
|
|
|
|
.tier-icon {
|
|
width: 1.2em;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tier-name {
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.tier-progress {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-size: var(--yj-text-xs, 11px);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.tier-detail {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-size: var(--yj-text-xs, 11px);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.tier-error {
|
|
color: var(--yj-accent-error, #f44);
|
|
font-size: var(--yj-text-xs, 11px);
|
|
}
|
|
|
|
.index-ready {
|
|
margin-top: 1em;
|
|
font-size: var(--yj-text-sm);
|
|
color: var(--yj-accent-success, #4a4);
|
|
}
|
|
|
|
.index-waiting, .index-loading {
|
|
font-size: var(--yj-text-sm);
|
|
color: var(--yj-text-tertiary, #888);
|
|
}
|
|
|
|
.index-status-failed {
|
|
align-items: center;
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
display: flex;
|
|
font-size: var(--yj-text-sm);
|
|
gap: 0.5em;
|
|
}
|
|
|
|
.index-status-failed .link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
cursor: pointer;
|
|
font: inherit;
|
|
padding: 0;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
`;
|
|
|
|
// ===================================================================
|
|
// LIFECYCLE
|
|
// ===================================================================
|
|
|
|
protected override onViewActivate(): void {
|
|
void this.loadLibraries();
|
|
void this.loadPlaylists();
|
|
this.scrollMode =
|
|
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
|
|
|
|
// Scanning is started and watched here (#27), so the job
|
|
// snapshot is a live input to this page.
|
|
this.unsubscribeJobs = jobStore.subscribe(() => {
|
|
this.jobs = jobStore.jobs;
|
|
});
|
|
this.jobs = jobStore.jobs;
|
|
void jobStore.init();
|
|
|
|
this.cancelLibraryAdded = EventsOn(
|
|
Events.LibraryAdded,
|
|
() => void this.loadLibraries(),
|
|
);
|
|
this.cancelLibraryRenamed = EventsOn(
|
|
Events.LibraryRenamed,
|
|
() => void this.loadLibraries(),
|
|
);
|
|
this.cancelLibraryRemoved = EventsOn(
|
|
Events.LibraryRemoved,
|
|
() => void this.loadLibraries(),
|
|
);
|
|
|
|
this.listenWhileActive(document, 'click', this.handleDocumentClick);
|
|
|
|
// Listen for index status events (pushed from Go, no binding calls).
|
|
this.cancelIndexStatus = EventsOn(
|
|
Events.IndexStatusChanged,
|
|
(status: explore.IndexStatus) => {
|
|
this.indexStatus = status;
|
|
this.indexStatusFailed = false;
|
|
},
|
|
);
|
|
|
|
// …and pull the current one, because that event only fires when
|
|
// a build *changes* state, and the steady state is no build.
|
|
void this.loadIndexStatus();
|
|
}
|
|
|
|
protected override onViewDeactivate(): void {
|
|
this.unsubscribeJobs?.();
|
|
this.unsubscribeJobs = null;
|
|
|
|
this.cancelLibraryAdded?.();
|
|
this.cancelLibraryRenamed?.();
|
|
this.cancelLibraryRemoved?.();
|
|
|
|
this.cancelIndexStatus?.();
|
|
}
|
|
|
|
private async loadLibraries(): Promise<void> {
|
|
try {
|
|
const [
|
|
libs,
|
|
mode,
|
|
defaultPage,
|
|
queueFallback,
|
|
allowMetered,
|
|
popupVolume,
|
|
] = await Promise.all([
|
|
GetAllLibrariesWithTrackCounts(),
|
|
GetScanConcurrency(),
|
|
GetDefaultPage(),
|
|
GetQueueFallback(),
|
|
GetAllowMeteredCatalogDownload(),
|
|
GetPopupVolume(),
|
|
]);
|
|
|
|
this.libraries = libs ?? [];
|
|
this.concurrencyMode = mode;
|
|
this.defaultPage = defaultPage;
|
|
this.queueFallback = queueFallback;
|
|
this.allowMeteredCatalogDownload = allowMetered;
|
|
this.popupVolume = popupVolume;
|
|
|
|
} catch (err) {
|
|
console.error(
|
|
'Failed to load libraries:',
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// LIBRARY HANDLERS
|
|
// ===================================================================
|
|
|
|
private handleAddLibrary = async (): Promise<void> => {
|
|
let dir = '';
|
|
|
|
try {
|
|
dir = (await pickDirectory()) ?? '';
|
|
|
|
if (!dir) return;
|
|
|
|
await AddLibrary(dir);
|
|
} catch (err) {
|
|
console.error('Failed to add library:', err);
|
|
notificationStore.persistent({
|
|
key: 'library-add',
|
|
title: 'Library not added',
|
|
text: explainError(
|
|
err,
|
|
'That folder could not be added as a library.',
|
|
),
|
|
detail: dir,
|
|
action: {
|
|
label: 'Try again',
|
|
run: () => void this.handleAddLibrary(),
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
private handleStartRename = (id: number, name: string): void => {
|
|
this.editingLibraryId = id;
|
|
this.editingName = name;
|
|
this.activeMenuId = null;
|
|
};
|
|
|
|
private handleRenameKeyDown = async (e: KeyboardEvent): Promise<void> => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
|
|
if (this.editingLibraryId !== null && this.editingName.trim()) {
|
|
const name = this.editingName.trim();
|
|
|
|
try {
|
|
await RenameLibrary(this.editingLibraryId, name);
|
|
} catch (err) {
|
|
console.error('Failed to rename library:', err);
|
|
notificationStore.persistent({
|
|
key: 'library-rename',
|
|
title: 'Library not renamed',
|
|
text: explainError(
|
|
err,
|
|
`“${name}” could not be used as a library name.`,
|
|
),
|
|
detail: String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
this.editingLibraryId = null;
|
|
this.editingName = '';
|
|
} else if (e.key === 'Escape') {
|
|
this.editingLibraryId = null;
|
|
this.editingName = '';
|
|
}
|
|
};
|
|
|
|
private handleRenameInput = (e: InputEvent): void => {
|
|
this.editingName = (e.target as HTMLInputElement).value;
|
|
};
|
|
|
|
/**
|
|
* Say what will happen, then ask — through the one confirmation the
|
|
* app has, which is a `wa-dialog` and so brings the focus trap, the
|
|
* Escape handler and the focus restore the hand-rolled overlay this
|
|
* replaces had none of (a11y.16).
|
|
*/
|
|
private handleRemoveClick = async (id: number): Promise<void> => {
|
|
this.activeMenuId = null;
|
|
|
|
const lib = this.libraries.find((l) => l.id === id);
|
|
const libName = lib?.name ?? 'Library';
|
|
let impact: library.RemovalImpact | null = null;
|
|
|
|
try {
|
|
impact = await GetRemovalImpact(id);
|
|
} catch (err) {
|
|
// Asking without the impact is worse than not asking at all,
|
|
// so this is a failure the user has to see rather than a
|
|
// confirmation with a blank consequence.
|
|
console.error('Failed to get removal impact:', err);
|
|
notificationStore.persistent({
|
|
key: 'library-remove',
|
|
title: 'Library not removed',
|
|
text: `Could not work out what removing “${libName}” would delete. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const ok = await confirmAction({
|
|
title: 'Remove library',
|
|
message: `Remove “${libName}”?`,
|
|
impact: `This deletes ${impact?.trackCount ?? 0} tracks, affects `
|
|
+ `${impact?.playlistsAffected ?? 0} playlists and removes `
|
|
+ `${impact?.queueItemCount ?? 0} queue items.`,
|
|
confirmLabel: 'Remove',
|
|
danger: true,
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
await this.removeLibrary(id, libName);
|
|
};
|
|
|
|
private async removeLibrary(id: number, libName: string): Promise<void> {
|
|
this.removingLibraryId = id;
|
|
|
|
try {
|
|
const summary = await RemoveLibrary(id);
|
|
|
|
this.removingLibraryId = null;
|
|
notificationStore.transient({
|
|
tone: 'success',
|
|
key: 'library-remove',
|
|
text: `Removed “${libName}” — ${summary?.tracksDeleted ?? 0} tracks deleted.`,
|
|
});
|
|
void this.loadLibraries();
|
|
} catch (err) {
|
|
this.removingLibraryId = null;
|
|
console.error('Failed to remove library:', err);
|
|
notificationStore.persistent({
|
|
key: 'library-remove',
|
|
title: 'Library not removed',
|
|
text: `Could not remove “${libName}”. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
private toggleOverflowMenu = (id: number, e: Event): void => {
|
|
e.stopPropagation();
|
|
this.activeMenuId = this.activeMenuId === id ? null : id;
|
|
};
|
|
|
|
private handleDocumentClick = (): void => {
|
|
if (this.activeMenuId !== null) {
|
|
this.activeMenuId = null;
|
|
}
|
|
|
|
if (this.editingLibraryId !== null) {
|
|
this.editingLibraryId = null;
|
|
this.editingName = '';
|
|
}
|
|
};
|
|
|
|
private async loadIndexStatus(): Promise<void> {
|
|
try {
|
|
this.indexStatus = await GetIndexStatus();
|
|
this.indexStatusFailed = false;
|
|
} catch (err) {
|
|
console.error('Failed to read index status:', err);
|
|
this.indexStatusFailed = true;
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// SCANNING (#27 — back from the Jobs tab)
|
|
// ===================================================================
|
|
|
|
/** The scan job for a library, if one is registered. */
|
|
private jobForLibrary(id: number): Job | undefined {
|
|
return this.jobs.find((job) => job.id === `scan:${id}`);
|
|
}
|
|
|
|
/** The status line under a library name while it is being scanned. */
|
|
private libraryScanStatus(id: number): string | null {
|
|
const job = this.jobForLibrary(id);
|
|
|
|
if (!job) return null;
|
|
|
|
switch (job.state) {
|
|
case 'running':
|
|
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
|
|
case 'queued':
|
|
return 'Queued';
|
|
case 'paused':
|
|
return 'Paused';
|
|
case 'pausing':
|
|
return 'Pausing…';
|
|
case 'cancelling':
|
|
return 'Stopping…';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private get anyScanning(): boolean {
|
|
return this.libraries.some(
|
|
(lib) => this.libraryScanStatus(lib.id) !== null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run something that starts a job, holding the buttons until the
|
|
* snapshot lands and saying so when it does not start at all.
|
|
*
|
|
* Persistent, not a toast: the user asked for work to happen, it
|
|
* did not, and retrying is exactly the useful response.
|
|
*/
|
|
private async startJob(
|
|
what: string,
|
|
start: () => Promise<unknown>,
|
|
retry: () => void,
|
|
): Promise<void> {
|
|
if (this.startingScan) return;
|
|
|
|
this.startingScan = true;
|
|
|
|
try {
|
|
await start();
|
|
} catch (err) {
|
|
console.error(`${what} failed:`, err);
|
|
notificationStore.persistent({
|
|
key: 'scan-start',
|
|
title: 'Scan did not start',
|
|
text: `${what} failed. ${describeError(err)}`,
|
|
detail: String(err),
|
|
action: { label: 'Try again', run: retry },
|
|
});
|
|
} finally {
|
|
this.startingScan = false;
|
|
}
|
|
}
|
|
|
|
private handleScanLibrary = (id: number): void => {
|
|
this.activeMenuId = null;
|
|
void this.startJob(
|
|
'Scanning that library',
|
|
() => ScanLibrary(id),
|
|
() => this.handleScanLibrary(id),
|
|
);
|
|
};
|
|
|
|
private handleScanAll = (): void => {
|
|
void this.startJob(
|
|
'Scanning your libraries',
|
|
() => ScanAllLibraries(),
|
|
() => this.handleScanAll(),
|
|
);
|
|
};
|
|
|
|
private handleFullRescan = async (): Promise<void> => {
|
|
const ok = await confirmAction({
|
|
title: 'Full rescan',
|
|
message:
|
|
'This deletes all library data — including downloaded '
|
|
+ 'cover art — and rebuilds it from your files.',
|
|
impact:
|
|
'It is not the same as “Scan now”, which only picks up '
|
|
+ 'what changed.',
|
|
confirmLabel: 'Rebuild everything',
|
|
danger: true,
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
await this.startJob(
|
|
'The full rescan',
|
|
() => FullRescan(),
|
|
() => void this.handleFullRescan(),
|
|
);
|
|
};
|
|
|
|
private handleViewToggle = (
|
|
view: string,
|
|
visible: boolean,
|
|
): void => {
|
|
this.viewsCtrl
|
|
.setVisible(view, visible)
|
|
.catch((err: unknown) => {
|
|
console.error('Failed to save view visibility:', err);
|
|
notificationStore.transient({
|
|
key: 'view-visibility',
|
|
text: `Could not change which views are shown. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
// The checkbox has already flipped itself; the store is
|
|
// the truth, so redraw from it.
|
|
this.requestUpdate();
|
|
});
|
|
};
|
|
|
|
private handleDefaultPageChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const page = String(e.detail.value);
|
|
|
|
SetDefaultPage(page)
|
|
.then(() => {
|
|
this.defaultPage = page;
|
|
notificationStore.transient({
|
|
tone: 'success',
|
|
key: 'default-page',
|
|
text: 'Launch page saved.',
|
|
});
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error('Failed to save launch page:', err);
|
|
notificationStore.transient({
|
|
key: 'default-page',
|
|
text: `Could not save the launch page. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
});
|
|
};
|
|
|
|
private handleQueueFallbackChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const mode = String(e.detail.value);
|
|
|
|
SetQueueFallback(mode)
|
|
.then(() => {
|
|
this.queueFallback = mode;
|
|
notificationStore.transient({
|
|
tone: 'success',
|
|
key: 'queue-fallback',
|
|
text: 'Queue fallback saved.',
|
|
});
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error('Failed to save queue fallback:', err);
|
|
notificationStore.transient({
|
|
key: 'queue-fallback',
|
|
text: `Could not save the queue fallback. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
});
|
|
};
|
|
|
|
private handleConcurrencyChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const mode = String(e.detail.value);
|
|
|
|
SetScanConcurrency(mode)
|
|
.then(() => {
|
|
this.concurrencyMode = mode;
|
|
notificationStore.transient({
|
|
tone: 'success',
|
|
key: 'storage-type',
|
|
text: 'Storage type saved. Takes effect on next scan.',
|
|
});
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error('Failed to save storage type:', err);
|
|
notificationStore.transient({
|
|
key: 'storage-type',
|
|
text: `Could not save the storage type. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
});
|
|
};
|
|
|
|
// ===================================================================
|
|
// THEME HANDLERS
|
|
// ===================================================================
|
|
|
|
private handleAccentChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
this.themeCtrl
|
|
.setAccentColor(String(e.detail.value))
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to set accent color:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
private handleShadeChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
this.themeCtrl
|
|
.setBackgroundShade(
|
|
String(
|
|
e.detail.value,
|
|
) as BackgroundShade,
|
|
)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to set background shade:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
// ===================================================================
|
|
// FAVORITES HANDLERS
|
|
// ===================================================================
|
|
|
|
private async loadPlaylists(): Promise<void> {
|
|
try {
|
|
this.playlists = await list(GetAllPlaylists());
|
|
} catch (err) {
|
|
console.error(
|
|
'Failed to load playlists:',
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
private handleFavIconStyleChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const style = String(
|
|
e.detail.value,
|
|
) as IconStyle;
|
|
|
|
this.favCtrl
|
|
.setIconStyle(style)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to set icon style:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
private handleFavPlaylistChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const id = Number(e.detail.value);
|
|
|
|
if (Number.isNaN(id)) return;
|
|
|
|
this.favCtrl
|
|
.setDefaultPlaylist(id)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to set default playlist:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
private handlePinDefaultChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const pin = Boolean(e.detail.value);
|
|
|
|
this.favCtrl
|
|
.setPinDefault(pin)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to set pin default:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
// ===================================================================
|
|
// KEYBOARD SHORTCUTS HANDLERS
|
|
// ===================================================================
|
|
|
|
private async handleShortcutChange(
|
|
e: CustomEvent<{ action: string; key: string }>,
|
|
) {
|
|
const { action, key } = e.detail;
|
|
|
|
// Check for conflict — find any other action with the same key in the same or overlapping scope
|
|
const meta = SHORTCUT_META[action];
|
|
const conflict = shortcutsStore.findConflict(
|
|
key,
|
|
meta?.scope ?? 'global',
|
|
action,
|
|
);
|
|
|
|
if (conflict) {
|
|
// Show conflict warning
|
|
this.shortcutConflict = {
|
|
newAction: action,
|
|
newKey: key,
|
|
existingAction: conflict.action,
|
|
};
|
|
return;
|
|
}
|
|
|
|
// No conflict — save directly
|
|
await shortcutsStore.updateBinding(action, key);
|
|
}
|
|
|
|
private async handleConflictOverwrite() {
|
|
if (!this.shortcutConflict) return;
|
|
const { newAction, newKey, existingAction } =
|
|
this.shortcutConflict;
|
|
// Unbind the existing action
|
|
await shortcutsStore.updateBinding(
|
|
existingAction,
|
|
'',
|
|
);
|
|
// Set the new binding
|
|
await shortcutsStore.updateBinding(
|
|
newAction,
|
|
newKey,
|
|
);
|
|
this.shortcutConflict = null;
|
|
}
|
|
|
|
private handleConflictCancel() {
|
|
this.shortcutConflict = null;
|
|
}
|
|
|
|
private async handleResetAllShortcuts() {
|
|
await shortcutsStore.resetAll();
|
|
}
|
|
|
|
// ===================================================================
|
|
// NOW PLAYING HANDLERS
|
|
// ===================================================================
|
|
|
|
private handleScrollModeChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const mode = String(e.detail.value);
|
|
this.scrollMode = mode;
|
|
localStorage.setItem(SCROLL_STORAGE_KEY, mode);
|
|
window.dispatchEvent(
|
|
new CustomEvent(SCROLL_CHANGE_EVENT),
|
|
);
|
|
};
|
|
|
|
// ===================================================================
|
|
// TRACK LIST COLUMN HANDLERS
|
|
// ===================================================================
|
|
|
|
private handleColumnToggle = (
|
|
columnId: string,
|
|
): void => {
|
|
const current = [
|
|
...this.trackListCtrl.columnIds,
|
|
];
|
|
const idx = current.indexOf(columnId);
|
|
|
|
if (idx >= 0) {
|
|
// Don't allow removing the last column.
|
|
if (current.length <= 1) return;
|
|
|
|
current.splice(idx, 1);
|
|
} else {
|
|
current.push(columnId);
|
|
}
|
|
|
|
this.trackListCtrl
|
|
.setColumns(current)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to update columns:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Builds the merged column order: enabled IDs first
|
|
* (in their configured display order), then disabled
|
|
* IDs (in default static order).
|
|
*/
|
|
private getMergedColumnOrder(): string[] {
|
|
const enabledIds = [
|
|
...this.trackListCtrl.columnIds,
|
|
];
|
|
|
|
const disabledIds = ALL_COLUMN_IDS.filter(
|
|
(id) => !enabledIds.includes(id),
|
|
);
|
|
|
|
return [...enabledIds, ...disabledIds];
|
|
}
|
|
|
|
private handleColumnMove = (
|
|
columnId: string,
|
|
direction: 'up' | 'down',
|
|
): void => {
|
|
const order = this.getMergedColumnOrder();
|
|
const idx = order.indexOf(columnId);
|
|
|
|
if (idx < 0) return;
|
|
|
|
const targetIdx =
|
|
direction === 'up' ? idx - 1 : idx + 1;
|
|
|
|
if (targetIdx < 0 || targetIdx >= order.length)
|
|
return;
|
|
|
|
// Swap adjacent items in the full list.
|
|
const tmp = order[targetIdx]!;
|
|
order[targetIdx] = order[idx]!;
|
|
order[idx] = tmp;
|
|
|
|
// Keep only the enabled columns, preserving
|
|
// the new order.
|
|
const enabledSet = new Set(
|
|
this.trackListCtrl.columnIds,
|
|
);
|
|
const newEnabled = order.filter((id) =>
|
|
enabledSet.has(id),
|
|
);
|
|
|
|
this.trackListCtrl
|
|
.setColumns(newEnabled)
|
|
.catch((err: unknown) => {
|
|
console.error(
|
|
'Failed to reorder columns:',
|
|
err,
|
|
);
|
|
});
|
|
};
|
|
|
|
// ===================================================================
|
|
// RENDER
|
|
// ===================================================================
|
|
|
|
override render() {
|
|
return html`
|
|
<h2>Settings</h2>
|
|
|
|
<!--
|
|
Ordered by how often a setting is *reached*, not by how
|
|
the sections were written (H-22). Libraries was last and
|
|
below the fold while Search Index — which is configured
|
|
once, if ever — was first and the only expanded one.
|
|
-->
|
|
${this.renderLibrarySection()}
|
|
${this.renderGeneralSection()}
|
|
${this.renderNavigationSection()}
|
|
${this.renderNowPlayingSection()}
|
|
${this.renderThemeSection()}
|
|
${this.renderTrackListSection()}
|
|
${this.renderFavoritesSection()}
|
|
${this.renderShortcutsSection()}
|
|
${this.renderSearchSection()}
|
|
<download-clients></download-clients>
|
|
`;
|
|
}
|
|
|
|
// --- Search / Index section ---
|
|
|
|
private renderSearchSection() {
|
|
const s = this.indexStatus;
|
|
|
|
return html`
|
|
<config-section
|
|
heading="Search Index"
|
|
description="The explore search index is built from the MusicBrainz/ListenBrainz data dumps — popular artists, albums, and tracks with listen counts — for fast offline search."
|
|
>
|
|
<div class="index-status">
|
|
${s
|
|
? html`
|
|
<div class="index-stats">
|
|
<span class="index-stat">${this.formatCount(s.artists)} artists</span>
|
|
<span class="index-stat-sep">·</span>
|
|
<span class="index-stat">${this.formatCount(s.recordings)} recordings</span>
|
|
<span class="index-stat-sep">·</span>
|
|
<span class="index-stat">${this.formatCount(s.releaseGroups)} albums</span>
|
|
<span class="index-stat-sep">·</span>
|
|
<span class="index-stat">${this.formatCount(s.totalRows)} total</span>
|
|
${s.lastBuilt
|
|
? html`<span class="index-stat-sep">·</span>
|
|
<span class="index-stat">updated ${this.timeAgo(s.lastBuilt)}</span>`
|
|
: nothing}
|
|
</div>
|
|
${(s.tiers?.length ?? 0) > 0 && (s.tiers ?? []).some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error')
|
|
? html`
|
|
<div class="index-tiers">
|
|
${(s.tiers ?? []).map(
|
|
(t) => html`
|
|
<div class="index-tier">
|
|
<span class="tier-icon">${this.tierIcon(t.state)}</span>
|
|
<span class="tier-name">${t.name}</span>
|
|
${t.state === 'running' && t.total > 0
|
|
? html`<span class="tier-progress">${t.completed}/${t.total}</span>`
|
|
: nothing}
|
|
${t.state === 'running' && t.detail
|
|
? html`<span class="tier-detail">${t.detail}</span>`
|
|
: nothing}
|
|
${t.state === 'error'
|
|
? html`<span class="tier-error">${describeError(t.error, 'This part of the index could not be built.')}</span>`
|
|
: nothing}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${!s.building && s.ready
|
|
? html`<div class="index-ready">Index ready</div>`
|
|
: !s.building && !s.ready && s.totalRows === 0
|
|
? html`<div class="index-waiting">Index empty — build will start after library scan</div>`
|
|
: !s.building && !s.ready
|
|
? html`<div class="index-waiting">Waiting for index build…</div>`
|
|
: nothing}
|
|
`
|
|
: this.indexStatusFailed
|
|
? html`<div class="index-status-failed">
|
|
<span>The index status could not be read.</span>
|
|
<button
|
|
type="button"
|
|
class="link"
|
|
@click=${() => void this.loadIndexStatus()}
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>`
|
|
: html`<div class="index-loading">Loading status…</div>`}
|
|
</div>
|
|
|
|
<config-field
|
|
.schema=${{
|
|
key: 'allowMeteredCatalogDownload',
|
|
label: 'Download the catalog on mobile data',
|
|
description:
|
|
'The catalog is about 0.6 GB. It is skipped on a '
|
|
+ 'cellular connection unless this is on; a '
|
|
+ 'metered Wi-Fi network cannot be detected.',
|
|
type: 'toggle' as const,
|
|
}}
|
|
.value=${this.allowMeteredCatalogDownload}
|
|
@config-change=${this.handleAllowMeteredChange}
|
|
></config-field>
|
|
|
|
<!--
|
|
The tier list above says what the build is *doing*;
|
|
this says it is a job, and gives it the pause, cancel
|
|
and log the tier list never had (#27). Cancelling one
|
|
still asks first — that confirmation is inside
|
|
applyJobControl, keyed on the kind, which is why
|
|
this embeds the shared rows rather than drawing its
|
|
own.
|
|
-->
|
|
<job-panel
|
|
kinds="index-build,catalog-enrich"
|
|
heading="Index jobs"
|
|
></job-panel>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* The catalog download's one permission (plan 016 B4).
|
|
*
|
|
* It is in this section rather than General because it is about
|
|
* *this* download and nothing else, and because the section already
|
|
* explains what the catalog is — the toggle would be unreadable
|
|
* beside "Default page".
|
|
*/
|
|
private handleAllowMeteredChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const allow = Boolean(e.detail.value);
|
|
const previous = this.allowMeteredCatalogDownload;
|
|
|
|
this.allowMeteredCatalogDownload = allow;
|
|
|
|
void SetAllowMeteredCatalogDownload(allow).catch((err: unknown) => {
|
|
console.error('failed to save metered download permission', err);
|
|
// The visible state reverted, so this is the Transient case:
|
|
// a small action the user can simply repeat.
|
|
this.allowMeteredCatalogDownload = previous;
|
|
notificationStore.transient({
|
|
key: 'metered-catalog-setting',
|
|
title: 'Setting not saved',
|
|
text: describeError(err, 'That setting could not be saved.'),
|
|
});
|
|
});
|
|
};
|
|
|
|
private tierIcon(state: string): string {
|
|
switch (state) {
|
|
case 'complete':
|
|
case 'skipped':
|
|
return '✅';
|
|
case 'running':
|
|
return '🔄';
|
|
case 'error':
|
|
return '❌';
|
|
case 'pending':
|
|
default:
|
|
return '⏳';
|
|
}
|
|
}
|
|
|
|
private formatCount(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return `${n}`;
|
|
}
|
|
|
|
private timeAgo(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
if (!then) return '';
|
|
const seconds = Math.floor((Date.now() - then) / 1000);
|
|
if (seconds < 60) return 'just now';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return 'yesterday';
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
// --- Now Playing section ---
|
|
|
|
private renderNowPlayingSection() {
|
|
return html`
|
|
<config-section
|
|
heading="Now Playing"
|
|
description="Configure the now-playing display in the bottom bar."
|
|
>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'scrollMode',
|
|
label: 'Text Scroll Behaviour',
|
|
description:
|
|
'How overflowing track title and artist text is handled when it exceeds the available width.',
|
|
type: 'select' as const,
|
|
options: [
|
|
{
|
|
value: 'hover',
|
|
label: 'Scroll on Hover (Default)',
|
|
},
|
|
{
|
|
value: 'always',
|
|
label: 'Always Scroll',
|
|
},
|
|
{
|
|
value: 'never',
|
|
label: 'Never (Ellipsis)',
|
|
},
|
|
],
|
|
}}
|
|
.value=${this.scrollMode}
|
|
@config-change=${this.handleScrollModeChange}
|
|
></config-field>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
// --- General section ---
|
|
|
|
private renderGeneralSection() {
|
|
return html`
|
|
<config-section
|
|
heading="General"
|
|
description="General app behaviour."
|
|
>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'defaultPage',
|
|
label: 'Launch Page',
|
|
description:
|
|
'The page the app opens to on launch.',
|
|
type: 'select' as const,
|
|
// Derived, not written out: this list was a
|
|
// second copy of the launchable set, and #27
|
|
// removing a destination is exactly the change
|
|
// that would have left the two disagreeing.
|
|
// Settings is excluded because it is the one
|
|
// view the backend refuses to launch into.
|
|
options: VIEW_META
|
|
.filter((v) => v.alwaysShown !== true)
|
|
.map((v) => ({ value: v.id, label: v.label })),
|
|
}}
|
|
.value=${this.defaultPage}
|
|
@config-change=${this.handleDefaultPageChange}
|
|
></config-field>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'queueFallback',
|
|
label: 'When the Queue Ends',
|
|
description:
|
|
'What plays, if anything, once the queue runs out.',
|
|
type: 'select' as const,
|
|
options: [
|
|
{ value: 'favorites', label: 'Play Favorites' },
|
|
{ value: 'dynamicMix', label: 'Start a Dynamic Mix' },
|
|
{ value: 'stop', label: 'Stop' },
|
|
],
|
|
}}
|
|
.value=${this.queueFallback}
|
|
@config-change=${this.handleQueueFallbackChange}
|
|
></config-field>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'popupVolume',
|
|
label: 'Volume opens in a popup',
|
|
description:
|
|
'Off, the volume slider is always visible in the '
|
|
+ 'player bar. On, it hides behind the speaker '
|
|
+ 'icon. The slider stands down on a phone either '
|
|
+ 'way, where the hardware keys own volume.',
|
|
type: 'toggle' as const,
|
|
}}
|
|
.value=${this.popupVolume}
|
|
@config-change=${this.handlePopupVolumeChange}
|
|
></config-field>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* The volume control's presentation (#42).
|
|
*
|
|
* In General rather than beside the theme because it is about the
|
|
* transport's behaviour rather than its colours, and next to "When
|
|
* the Queue Ends" because both are answers to "how should the
|
|
* player behave".
|
|
*/
|
|
private handlePopupVolumeChange = (
|
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
|
): void => {
|
|
const popup = Boolean(e.detail.value);
|
|
const previous = this.popupVolume;
|
|
|
|
this.popupVolume = popup;
|
|
|
|
void SetPopupVolume(popup).catch((err: unknown) => {
|
|
console.error('failed to save the volume control setting', err);
|
|
this.popupVolume = previous;
|
|
notificationStore.transient({
|
|
key: 'popup-volume-setting',
|
|
title: 'Setting not saved',
|
|
text: describeError(err, 'That setting could not be saved.'),
|
|
});
|
|
});
|
|
};
|
|
|
|
// --- Navigation section ---
|
|
|
|
/**
|
|
* Which destinations the sidebar and the phone's tab bar offer.
|
|
*
|
|
* Two items are drawn but not editable, and both say why in place
|
|
* rather than being silently inert. Settings is never hideable --
|
|
* the backend refuses it too, because `config.toml` is
|
|
* hand-editable. The launch page is not hideable *while it is the
|
|
* launch page*, which is a state the user can leave by changing the
|
|
* launch page above; refusing is preferable to the alternatives,
|
|
* since resetting their launch page silently changes a second thing
|
|
* they chose and allowing it lands the app on a page nothing points
|
|
* at.
|
|
*/
|
|
private renderNavigationSection() {
|
|
return html`
|
|
<config-section
|
|
heading="Navigation"
|
|
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
|
|
>
|
|
<ul class="column-list">
|
|
${repeat(VIEW_META, (v) => v.id, (v) => {
|
|
const checked = this.viewsCtrl.enabled(v.id);
|
|
const isLaunchPage = this.defaultPage === v.id;
|
|
const locked = v.alwaysShown === true || isLaunchPage;
|
|
|
|
let note = '';
|
|
|
|
if (v.alwaysShown === true) {
|
|
note = 'Always shown.';
|
|
} else if (isLaunchPage) {
|
|
note = 'This is the launch page.';
|
|
} else if (
|
|
v.id === 'downloads' &&
|
|
checked &&
|
|
!downloadStore.available
|
|
) {
|
|
// The config says show it and the nav does not, which
|
|
// would otherwise read as the checkbox not working.
|
|
note = 'Hidden until a download client is configured.';
|
|
}
|
|
|
|
return html`
|
|
<li
|
|
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
|
>
|
|
<input
|
|
id="view-${v.id}"
|
|
type="checkbox"
|
|
class="column-toggle"
|
|
aria-label="Show ${v.label} in the navigation"
|
|
.checked=${checked}
|
|
?disabled=${locked}
|
|
@change=${(e: Event) =>
|
|
this.handleViewToggle(
|
|
v.id,
|
|
(e.target as HTMLInputElement).checked,
|
|
)}
|
|
/>
|
|
<label class="column-label" for="view-${v.id}">
|
|
${v.label}
|
|
</label>
|
|
${note
|
|
? html`<span class="view-note">${note}</span>`
|
|
: nothing}
|
|
</li>
|
|
`;
|
|
})}
|
|
</ul>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
// --- Theme section ---
|
|
|
|
private renderThemeSection() {
|
|
return html`
|
|
<config-section
|
|
heading="Theme"
|
|
description="Customise the app's colour scheme."
|
|
>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'accentColor',
|
|
label: 'Accent Colour',
|
|
description:
|
|
'The primary highlight colour used for active items, buttons, and indicators.',
|
|
type: 'color' as const,
|
|
}}
|
|
.value=${this.themeCtrl.accentColor}
|
|
@config-change=${this.handleAccentChange}
|
|
></config-field>
|
|
|
|
<config-field
|
|
.schema=${{
|
|
key: 'backgroundShade',
|
|
label: 'Background Shade',
|
|
description:
|
|
'Controls the overall brightness of the interface.',
|
|
type: 'select' as const,
|
|
options: [
|
|
{
|
|
value: 'darker',
|
|
label: 'Darker (OLED)',
|
|
},
|
|
{
|
|
value: 'dark',
|
|
label: 'Dark (Default)',
|
|
},
|
|
{
|
|
value: 'light',
|
|
label: 'Light',
|
|
},
|
|
],
|
|
}}
|
|
.value=${this.themeCtrl
|
|
.backgroundShade}
|
|
@config-change=${this.handleShadeChange}
|
|
></config-field>
|
|
|
|
<div class="color-preview">
|
|
${this.renderSwatches()}
|
|
</div>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
private renderSwatches() {
|
|
const swatches = [
|
|
{ label: 'Base', var: '--yj-bg-base' },
|
|
{
|
|
label: 'Surface',
|
|
var: '--yj-bg-surface',
|
|
},
|
|
{
|
|
label: 'Elevated',
|
|
var: '--yj-bg-elevated',
|
|
},
|
|
{
|
|
label: 'Overlay',
|
|
var: '--yj-bg-overlay',
|
|
},
|
|
{ label: 'Accent', var: '--yj-accent' },
|
|
];
|
|
|
|
return swatches.map(
|
|
(s) => html`
|
|
<div class="swatch-group">
|
|
<div
|
|
class="swatch"
|
|
style="background: var(${s.var})"
|
|
></div>
|
|
<span class="swatch-label">
|
|
${s.label}
|
|
</span>
|
|
</div>
|
|
`,
|
|
);
|
|
}
|
|
|
|
// --- Favorites section ---
|
|
|
|
private renderFavoritesSection() {
|
|
const playlistOptions =
|
|
this.playlists.map((p) => ({
|
|
value: String(p.ID),
|
|
label: p.Name,
|
|
}));
|
|
|
|
return html`
|
|
<config-section
|
|
heading="Favorites"
|
|
description="Configure the default playlist used for quick-favouriting tracks."
|
|
>
|
|
<config-field
|
|
.schema=${{
|
|
key: 'favoritesPlaylist',
|
|
label: 'Default Playlist',
|
|
description:
|
|
'The playlist used when toggling the favourite icon on tracks.',
|
|
type: 'select' as const,
|
|
options: playlistOptions,
|
|
}}
|
|
.value=${String(
|
|
this.favCtrl.playlistId,
|
|
)}
|
|
@config-change=${this.handleFavPlaylistChange}
|
|
></config-field>
|
|
|
|
<config-field
|
|
.schema=${{
|
|
key: 'favoritesIcon',
|
|
label: 'Icon Style',
|
|
description:
|
|
'Choose heart or star for the favourite indicator.',
|
|
type: 'select' as const,
|
|
options: [
|
|
{
|
|
value: 'heart',
|
|
label: '\u2665 Heart',
|
|
},
|
|
{
|
|
value: 'star',
|
|
label: '\u2605 Star',
|
|
},
|
|
],
|
|
}}
|
|
.value=${this.favCtrl
|
|
.iconStyle}
|
|
@config-change=${this.handleFavIconStyleChange}
|
|
></config-field>
|
|
|
|
<config-field
|
|
.schema=${{
|
|
key: 'pinDefaultPlaylist',
|
|
label: 'Pin to Top',
|
|
description:
|
|
'Always show the default playlist first, regardless of sort order.',
|
|
type: 'toggle' as const,
|
|
}}
|
|
.value=${this.favCtrl.pinDefault}
|
|
@config-change=${this.handlePinDefaultChange}
|
|
></config-field>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
// --- Track list section ---
|
|
|
|
private renderTrackListSection() {
|
|
const enabledIds = this.trackListCtrl.columnIds;
|
|
const order = this.getMergedColumnOrder();
|
|
|
|
return html`
|
|
<config-section
|
|
heading="Track List Columns"
|
|
description="Choose which columns are visible and set their display order."
|
|
>
|
|
<ul class="column-list">
|
|
${repeat(order, (id) => id, (id, idx) => {
|
|
const checked =
|
|
enabledIds.includes(id);
|
|
const onlyOne =
|
|
checked &&
|
|
enabledIds.length <= 1;
|
|
const isFirst = idx === 0;
|
|
const isLast =
|
|
idx === order.length - 1;
|
|
|
|
const columnLabel =
|
|
COLUMN_DEFS[id]?.label ?? id;
|
|
|
|
return html`
|
|
<li
|
|
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
|
>
|
|
<input
|
|
id="column-${id}"
|
|
type="checkbox"
|
|
class="column-toggle"
|
|
aria-label="Show the ${columnLabel} column"
|
|
.checked=${checked}
|
|
?disabled=${onlyOne}
|
|
@change=${() =>
|
|
this.handleColumnToggle(
|
|
id,
|
|
)}
|
|
/>
|
|
<label
|
|
class="column-label"
|
|
for="column-${id}"
|
|
>
|
|
${columnLabel}
|
|
</label>
|
|
<span
|
|
class="column-arrows"
|
|
>
|
|
${isFirst
|
|
? nothing
|
|
: html`
|
|
<button
|
|
class="column-arrow-btn"
|
|
title="Move up"
|
|
aria-label="Move ${columnLabel} up"
|
|
@click=${() =>
|
|
this.handleColumnMove(
|
|
id,
|
|
'up',
|
|
)}
|
|
>
|
|
▲
|
|
</button>
|
|
`}
|
|
${isLast
|
|
? nothing
|
|
: html`
|
|
<button
|
|
class="column-arrow-btn"
|
|
title="Move down"
|
|
aria-label="Move ${columnLabel} down"
|
|
@click=${() =>
|
|
this.handleColumnMove(
|
|
id,
|
|
'down',
|
|
)}
|
|
>
|
|
▼
|
|
</button>
|
|
`}
|
|
</span>
|
|
</li>
|
|
`;
|
|
})}
|
|
</ul>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
// --- Keyboard Shortcuts section ---
|
|
|
|
private renderShortcutsSection() {
|
|
const bindings =
|
|
this.shortcutsCtrl.state.bindings;
|
|
// All four, from the shared table: the Autotag bindings are
|
|
// persisted and rebindable like any other, and listing three of
|
|
// four categories is how they came to be written down nowhere.
|
|
const categories = SHORTCUT_CATEGORIES;
|
|
|
|
return html`
|
|
<config-section
|
|
heading="Keyboard Shortcuts"
|
|
description="Customise key bindings for player controls, navigation, and app actions."
|
|
>
|
|
${categories.map((cat) => {
|
|
const actions = Object.entries(
|
|
SHORTCUT_META,
|
|
).filter(
|
|
([, meta]) =>
|
|
meta.category === cat,
|
|
);
|
|
|
|
if (actions.length === 0) return '';
|
|
|
|
return html`
|
|
<div class="shortcut-category">
|
|
<div
|
|
class="shortcut-category-header"
|
|
>
|
|
${cat}
|
|
</div>
|
|
${actions.map(
|
|
([action, meta]) => html`
|
|
<div
|
|
class="shortcut-row"
|
|
>
|
|
<span
|
|
class="shortcut-label"
|
|
>
|
|
${meta.label}
|
|
${meta.scope !==
|
|
'global'
|
|
? html`
|
|
<span
|
|
class="shortcut-scope"
|
|
>(${meta.scope.replace(
|
|
'panel:',
|
|
'',
|
|
)})</span
|
|
>
|
|
`
|
|
: ''}
|
|
</span>
|
|
<shortcut-capture
|
|
.action=${action}
|
|
.label=${meta.label}
|
|
.currentKey=${bindings.get(
|
|
action,
|
|
) ?? ''}
|
|
.defaultKey=${meta.defaultKey}
|
|
@shortcut-change=${this
|
|
.handleShortcutChange}
|
|
></shortcut-capture>
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
})}
|
|
|
|
<div class="shortcut-actions">
|
|
<button
|
|
class="btn-ghost"
|
|
@click=${this
|
|
.handleResetAllShortcuts}
|
|
>
|
|
Reset All to Defaults
|
|
</button>
|
|
</div>
|
|
|
|
${this.shortcutConflict
|
|
? html`
|
|
<div class="conflict-banner">
|
|
<span
|
|
class="conflict-text"
|
|
>
|
|
<strong
|
|
>${this
|
|
.shortcutConflict
|
|
.newKey}</strong
|
|
>
|
|
is already bound to
|
|
<strong
|
|
>${SHORTCUT_META[
|
|
this
|
|
.shortcutConflict
|
|
.existingAction
|
|
]?.label ??
|
|
this
|
|
.shortcutConflict
|
|
.existingAction}</strong
|
|
>.
|
|
</span>
|
|
<div
|
|
class="conflict-actions"
|
|
>
|
|
<button
|
|
class="btn-warning"
|
|
@click=${this
|
|
.handleConflictOverwrite}
|
|
>
|
|
Overwrite
|
|
</button>
|
|
<button
|
|
class="btn-ghost"
|
|
@click=${this
|
|
.handleConflictCancel}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''}
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
// --- Library section ---
|
|
|
|
private renderLibrarySection() {
|
|
return html`
|
|
<config-section
|
|
heading="Libraries"
|
|
description="Manage your music library folders, and scan them
|
|
for new and changed files."
|
|
.open=${true}
|
|
>
|
|
<div class="library-scope">
|
|
<div>
|
|
<span class="scope-label">Showing</span>
|
|
<p class="scope-description">
|
|
Which library the Albums, Artists and Genres
|
|
views show. This is a view filter, not a
|
|
setting about the libraries themselves — the
|
|
list below is where they are added, renamed
|
|
and scanned.
|
|
</p>
|
|
</div>
|
|
<library-filter data-testid="settings-library-filter">
|
|
</library-filter>
|
|
</div>
|
|
|
|
<div class="scan-actions">
|
|
<button
|
|
class="btn-primary"
|
|
@click=${this.handleAddLibrary}
|
|
>
|
|
Add Library
|
|
</button>
|
|
<button
|
|
?disabled=${this.anyScanning
|
|
|| this.startingScan
|
|
|| this.libraries.length === 0}
|
|
@click=${this.handleScanAll}
|
|
>
|
|
Scan All
|
|
</button>
|
|
<button
|
|
class="btn-danger"
|
|
?disabled=${this.anyScanning
|
|
|| this.startingScan
|
|
|| this.libraries.length === 0}
|
|
@click=${this.handleFullRescan}
|
|
>
|
|
Full Rescan
|
|
</button>
|
|
</div>
|
|
|
|
${this.libraries.length > 0
|
|
? html`
|
|
<div class="library-list">
|
|
${this.libraries.map(
|
|
(lib) => html`
|
|
<div class="library-row">
|
|
${this.editingLibraryId === lib.id
|
|
? html`
|
|
<input
|
|
class="edit-input"
|
|
type="text"
|
|
.value=${this.editingName}
|
|
@input=${this.handleRenameInput}
|
|
@keydown=${this.handleRenameKeyDown}
|
|
@click=${(e: Event) => e.stopPropagation()}
|
|
/>
|
|
`
|
|
: html`
|
|
<span
|
|
class="library-name"
|
|
@click=${(e: Event) => {
|
|
// The document handler closes the
|
|
// editor, and without this it closes
|
|
// the one this very click opened.
|
|
e.stopPropagation();
|
|
this.handleStartRename(lib.id, lib.name);
|
|
}}
|
|
>
|
|
${lib.name}
|
|
</span>
|
|
`}
|
|
<span class="library-path">${lib.path}</span>
|
|
<span class="library-count">
|
|
${this.removingLibraryId === lib.id
|
|
? 'Removing…'
|
|
: this.libraryScanStatus(lib.id)
|
|
?? html`${lib.trackCount} tracks`}
|
|
</span>
|
|
<div class="overflow-wrapper">
|
|
<button
|
|
class="overflow-btn"
|
|
aria-label=${`Actions for ${lib.name}`}
|
|
?disabled=${this.removingLibraryId === lib.id}
|
|
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
|
|
>
|
|
\u22EF
|
|
</button>
|
|
${this.activeMenuId === lib.id
|
|
? html`
|
|
<div
|
|
class="overflow-menu"
|
|
@click=${(e: Event) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
class="overflow-item"
|
|
@click=${() => this.handleStartRename(lib.id, lib.name)}
|
|
>
|
|
Rename
|
|
</div>
|
|
${this.libraryScanStatus(lib.id) === null
|
|
? html`
|
|
<div
|
|
class="overflow-item"
|
|
@click=${() => this.handleScanLibrary(lib.id)}
|
|
>
|
|
Scan now
|
|
</div>
|
|
`
|
|
: nothing}
|
|
<div
|
|
class="overflow-item overflow-item--danger"
|
|
@click=${() => void this.handleRemoveClick(lib.id)}
|
|
>
|
|
Remove
|
|
</div>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
|
|
<config-field
|
|
.schema=${{
|
|
key: 'concurrencyMode',
|
|
label: 'Storage Type',
|
|
description:
|
|
'Controls parallel workers during scanning. Auto-detect reads the disk type automatically.',
|
|
type: 'select' as const,
|
|
options: [
|
|
{
|
|
value: 'auto',
|
|
label: 'Auto-detect',
|
|
},
|
|
{
|
|
value: 'ssd',
|
|
label: 'SSD (max parallelism)',
|
|
},
|
|
{
|
|
value: 'hdd',
|
|
label: 'HDD (reduced I/O)',
|
|
},
|
|
],
|
|
}}
|
|
.value=${this.concurrencyMode}
|
|
@config-change=${this.handleConcurrencyChange}
|
|
></config-field>
|
|
|
|
<job-panel
|
|
kinds="library-scan"
|
|
heading="Scans"
|
|
></job-panel>
|
|
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'config-page': ConfigPage;
|
|
}
|
|
}
|