added color/ theme settings, new config window
This commit is contained in:
1 parent
fa6506ed5d
commit
88c3736110
43 files changed
+2314
-1207
No files matched your search
@@ -58,11 +58,11 @@ export class PlayerControls extends LitElement {
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.repeat-one {
|
||||
|
||||
@@ -22,23 +22,23 @@ export class SeekBar extends LitElement {
|
||||
--track-size: 6px;
|
||||
flex: 1;
|
||||
margin: 0 1em;
|
||||
--wa-tooltip-background-color: #343a40;
|
||||
--wa-tooltip-content-color: white;
|
||||
--wa-tooltip-border-color: #343a40;
|
||||
--wa-tooltip-background-color: var(--yj-bg-elevated, #343a40);
|
||||
--wa-tooltip-content-color: var(--yj-text-primary, white);
|
||||
--wa-tooltip-border-color: var(--yj-bg-elevated, #343a40);
|
||||
--wa-tooltip-border-radius: 4px;
|
||||
--wa-tooltip-font-size: 0.875em;
|
||||
}
|
||||
|
||||
wa-slider::part(track) {
|
||||
background: white;
|
||||
background: var(--yj-text-primary, white);
|
||||
}
|
||||
|
||||
wa-slider::part(indicator) {
|
||||
background: yellow;
|
||||
background: var(--yj-accent, yellow);
|
||||
}
|
||||
|
||||
wa-slider::part(thumb) {
|
||||
background: black;
|
||||
background: var(--yj-bg-base, black);
|
||||
}
|
||||
|
||||
#seek-bar-container {
|
||||
|
||||
@@ -35,8 +35,8 @@ export class VolumeControl extends LitElement {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
background: var(--yj-bg-surface, #1a1a1a);
|
||||
border: 1px solid var(--yj-border-subtle, #333);
|
||||
border-radius: 8px;
|
||||
padding: 1em 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
@@ -52,16 +52,16 @@ export class VolumeControl extends LitElement {
|
||||
}
|
||||
|
||||
wa-slider::part(track) {
|
||||
background: white;
|
||||
background: var(--yj-text-primary, white);
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
wa-slider::part(indicator) {
|
||||
background: yellow;
|
||||
background: var(--yj-accent, yellow);
|
||||
}
|
||||
|
||||
wa-slider::part(thumb) {
|
||||
background: black;
|
||||
background: var(--yj-bg-base, black);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
/**
|
||||
* Schema describing a single config field.
|
||||
*
|
||||
* The `type` controls which input widget is rendered:
|
||||
* - `text` – plain text input
|
||||
* - `select` – dropdown with `options`
|
||||
* - `color` – colour picker swatch
|
||||
* - `directory` – readonly text + browse button
|
||||
* - `number` – numeric input
|
||||
* - `toggle` – on/off switch
|
||||
*/
|
||||
export interface ConfigFieldSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type:
|
||||
| 'text'
|
||||
| 'select'
|
||||
| 'color'
|
||||
| 'directory'
|
||||
| 'number'
|
||||
| 'toggle';
|
||||
options?: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigFieldChangeEvent {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
@customElement('config-field')
|
||||
export class ConfigField extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
color-scheme: inherit;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.75em;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border: 1px solid var(--yj-border-subtle, #333);
|
||||
border-radius: 4px;
|
||||
padding: 0.4em 0.6em;
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 1px solid var(--yj-accent, #ffd43b);
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
opacity: 0.8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border: 1px solid var(--yj-border-subtle, #333);
|
||||
border-radius: 4px;
|
||||
padding: 0.4em 0.6em;
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: 1px solid var(--yj-accent, #ffd43b);
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
select option {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--yj-info, #4263eb);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4em 0.8em;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--yj-info-hover, #3b5bdb);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Colour picker */
|
||||
.color-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
input[type='color'] {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
border: 2px solid var(--yj-border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
input[type='color']::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
input[type='color']::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 2.5em;
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
border-radius: 1em;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
left: 0.2em;
|
||||
bottom: 0.2em;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(1.1em);
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
schema!: ConfigFieldSchema;
|
||||
|
||||
@property({ attribute: false })
|
||||
value: unknown = '';
|
||||
|
||||
override render() {
|
||||
if (!this.schema) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="field">
|
||||
${this.schema.type === 'toggle'
|
||||
? this.renderToggle()
|
||||
: html`
|
||||
<label>${this.schema.label}</label>
|
||||
${this.renderInput()}
|
||||
`}
|
||||
${this.schema.description
|
||||
? html`<p class="description">
|
||||
${this.schema.description}
|
||||
</p>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInput() {
|
||||
switch (this.schema.type) {
|
||||
case 'text':
|
||||
return this.renderText();
|
||||
case 'number':
|
||||
return this.renderNumber();
|
||||
case 'select':
|
||||
return this.renderSelect();
|
||||
case 'color':
|
||||
return this.renderColor();
|
||||
case 'directory':
|
||||
return this.renderDirectory();
|
||||
default:
|
||||
return html`<p>Unsupported field type</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
private renderText() {
|
||||
return html`
|
||||
<input
|
||||
type="text"
|
||||
.value=${String(this.value ?? '')}
|
||||
?disabled=${this.schema.disabled}
|
||||
@change=${this.onTextChange}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNumber() {
|
||||
return html`
|
||||
<input
|
||||
type="number"
|
||||
.value=${String(this.value ?? '')}
|
||||
?disabled=${this.schema.disabled}
|
||||
@change=${this.onTextChange}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSelect() {
|
||||
const current = String(this.value ?? '');
|
||||
|
||||
return html`
|
||||
<select
|
||||
?disabled=${this.schema.disabled}
|
||||
@change=${this.onSelectChange}
|
||||
>
|
||||
${(this.schema.options ?? []).map(
|
||||
(opt) => html`
|
||||
<option
|
||||
value=${opt.value}
|
||||
?selected=${opt.value === current}
|
||||
>
|
||||
${opt.label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderColor() {
|
||||
const hex = String(this.value ?? '#ffffff');
|
||||
|
||||
return html`
|
||||
<div class="color-wrapper">
|
||||
<input
|
||||
type="color"
|
||||
.value=${hex}
|
||||
?disabled=${this.schema.disabled}
|
||||
@input=${this.onColorInput}
|
||||
/>
|
||||
<span class="color-hex">${hex}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDirectory() {
|
||||
return html`
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="text"
|
||||
.value=${String(this.value ?? '')}
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
?disabled=${this.schema.disabled}
|
||||
@click=${this.onBrowseClick}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderToggle() {
|
||||
const checked = Boolean(this.value);
|
||||
|
||||
return html`
|
||||
<div class="toggle-row">
|
||||
<label>${this.schema.label}</label>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${checked}
|
||||
?disabled=${this.schema.disabled}
|
||||
@change=${this.onToggleChange}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// EVENT DISPATCHERS
|
||||
// ===================================================================
|
||||
|
||||
private emitChange(value: unknown): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ConfigFieldChangeEvent>(
|
||||
'config-change',
|
||||
{
|
||||
detail: {
|
||||
key: this.schema.key,
|
||||
value,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private onTextChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.emitChange(input.value);
|
||||
};
|
||||
|
||||
private onSelectChange = (e: Event) => {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
this.emitChange(select.value);
|
||||
};
|
||||
|
||||
private onColorInput = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.value = input.value;
|
||||
this.emitChange(input.value);
|
||||
};
|
||||
|
||||
private onBrowseClick = () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('config-browse', {
|
||||
detail: { key: this.schema.key },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
private onToggleChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.emitChange(input.checked);
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'config-field': ConfigField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1000 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Scan, FullRescan } from '@go/library/Library';
|
||||
import {
|
||||
GetLibraryDirectory,
|
||||
SetLibraryDirectory,
|
||||
GetScanConcurrency,
|
||||
SetScanConcurrency,
|
||||
} from '@go/config/Config';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { ThemeController } from '@store/controllers/theme-controller';
|
||||
import { Events } from '../../events';
|
||||
import type { ConfigFieldChangeEvent } from './config-field';
|
||||
import type { BackgroundShade } from '@store/theme-store';
|
||||
|
||||
import './config-field';
|
||||
import './config-section';
|
||||
|
||||
// ===================================================================
|
||||
// Scan metrics types and helpers (carried over from library-manager)
|
||||
// ===================================================================
|
||||
|
||||
const NS_PER_MS = 1_000_000;
|
||||
|
||||
interface ScanMetrics {
|
||||
total: number;
|
||||
loadExisting: number;
|
||||
walkDuration: number;
|
||||
extractionWallClock: number;
|
||||
dbWritesWallClock: number;
|
||||
orphanCleanup: number;
|
||||
postScanVariants: number;
|
||||
formatExtraction: Record<string, number>;
|
||||
formatCount: Record<string, number>;
|
||||
tagExtraction: number;
|
||||
durationExtraction: number;
|
||||
batchCommits: number;
|
||||
coverArtSave: number;
|
||||
thumbnailWallClock: number;
|
||||
thumbnailGeneration: number;
|
||||
thumbnailSmall: number;
|
||||
thumbnailMedium: number;
|
||||
thumbnailLarge: number;
|
||||
clearQueue: number;
|
||||
clearDatabase: number;
|
||||
clearCoverFiles: number;
|
||||
added: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
removed: number;
|
||||
}
|
||||
|
||||
function fmtNs(ns: number): string {
|
||||
if (ns <= 0) return '<1ms';
|
||||
|
||||
const ms = ns / NS_PER_MS;
|
||||
|
||||
if (ms < 1) return '<1ms';
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
|
||||
const s = ms / 1000;
|
||||
|
||||
if (s < 60) return `${s.toFixed(2)}s`;
|
||||
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
|
||||
return `${m}m ${rem.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
if (ms <= 0) return '<1ms';
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
|
||||
const s = ms / 1000;
|
||||
|
||||
if (s < 60) return `${s.toFixed(2)}s`;
|
||||
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
|
||||
return `${m}m ${rem.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatMetricsText(m: ScanMetrics): string {
|
||||
const line = (
|
||||
label: string,
|
||||
value: string,
|
||||
indent = 0,
|
||||
) => `${' '.repeat(indent)}${label}: ${value}`;
|
||||
|
||||
const lines: string[] = [
|
||||
'=== YellowJacket Scan Results ===',
|
||||
'',
|
||||
line('Total', fmtNs(m.total)),
|
||||
'',
|
||||
'-- File Counts --',
|
||||
line('Added', String(m.added), 1),
|
||||
line('Updated', String(m.updated), 1),
|
||||
line('Skipped', String(m.skipped), 1),
|
||||
line('Removed', String(m.removed), 1),
|
||||
];
|
||||
|
||||
if (
|
||||
m.clearQueue > 0 ||
|
||||
m.clearDatabase > 0 ||
|
||||
m.clearCoverFiles > 0
|
||||
) {
|
||||
lines.push(
|
||||
'',
|
||||
'-- Clear Phases --',
|
||||
line('Clear Queue', fmtNs(m.clearQueue), 1),
|
||||
line(
|
||||
'Clear Database',
|
||||
fmtNs(m.clearDatabase),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Clear Cover Files',
|
||||
fmtNs(m.clearCoverFiles),
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'-- Scan Phases --',
|
||||
line(
|
||||
'Load Existing',
|
||||
fmtNs(m.loadExisting),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Directory Walk',
|
||||
fmtNs(m.walkDuration),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Metadata Extraction (wall)',
|
||||
fmtNs(m.extractionWallClock),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'DB Writes (wall)',
|
||||
fmtNs(m.dbWritesWallClock),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Thumbnail Generation (wall)',
|
||||
fmtNs(m.thumbnailWallClock),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Orphan Cleanup',
|
||||
fmtNs(m.orphanCleanup),
|
||||
1,
|
||||
),
|
||||
line(
|
||||
'Post-Scan Variants',
|
||||
fmtNs(m.postScanVariants),
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Config page component
|
||||
// ===================================================================
|
||||
|
||||
@customElement('config-page')
|
||||
export class ConfigPage extends LitElement {
|
||||
// --- Theme controller for reading/writing theme state ---
|
||||
private themeCtrl = new ThemeController(this);
|
||||
|
||||
// --- Library state ---
|
||||
@state() private libraryDirectory = '';
|
||||
@state() private selectedDirectory = '';
|
||||
@state() private scanning = false;
|
||||
@state() private statusMessage = '';
|
||||
@state() private metrics: ScanMetrics | null = null;
|
||||
@state() private copied = false;
|
||||
@state() private concurrencyMode = 'auto';
|
||||
|
||||
private cancelScanStarted?: () => void;
|
||||
private cancelScanComplete?: () => void;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5em;
|
||||
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;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--yj-warning, #e8590c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: var(--yj-warning-hover, #d9480f);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--yj-error, #e03131);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--yj-error-hover, #c92a2a);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--yj-success, #2f9e44);
|
||||
color: #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, #2f9e44);
|
||||
}
|
||||
|
||||
/* Scan actions */
|
||||
.scan-actions {
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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, #ffd43b);
|
||||
}
|
||||
|
||||
/* 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, #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;
|
||||
}
|
||||
`;
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE
|
||||
// ===================================================================
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.loadLibraryConfig();
|
||||
|
||||
this.cancelScanStarted = EventsOn(
|
||||
Events.LibraryScanStarted,
|
||||
this.handleScanStarted,
|
||||
);
|
||||
this.cancelScanComplete = EventsOn(
|
||||
Events.LibraryScanComplete,
|
||||
this.handleScanComplete,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanStarted?.();
|
||||
this.cancelScanComplete?.();
|
||||
}
|
||||
|
||||
private async loadLibraryConfig(): Promise<void> {
|
||||
try {
|
||||
const [dir, mode] = await Promise.all([
|
||||
GetLibraryDirectory(),
|
||||
GetScanConcurrency(),
|
||||
]);
|
||||
|
||||
this.libraryDirectory = dir;
|
||||
this.selectedDirectory = dir;
|
||||
this.concurrencyMode = mode;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to load library config:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIBRARY HANDLERS
|
||||
// ===================================================================
|
||||
|
||||
private handleScanStarted = (): void => {
|
||||
this.scanning = true;
|
||||
this.statusMessage = 'Scanning...';
|
||||
this.metrics = null;
|
||||
this.copied = false;
|
||||
};
|
||||
|
||||
private handleScanComplete = (
|
||||
metrics?: ScanMetrics,
|
||||
): void => {
|
||||
this.scanning = false;
|
||||
this.statusMessage = 'Scan complete.';
|
||||
|
||||
if (metrics) {
|
||||
this.metrics = metrics;
|
||||
}
|
||||
};
|
||||
|
||||
private handleDirectoryBrowse = async (): Promise<void> => {
|
||||
try {
|
||||
const dir = await DirectoryPicker();
|
||||
|
||||
if (dir) {
|
||||
this.selectedDirectory = dir;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Directory picker failed:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private handleSaveDirectory = async (): Promise<void> => {
|
||||
if (!this.selectedDirectory) return;
|
||||
|
||||
try {
|
||||
await SetLibraryDirectory(
|
||||
this.selectedDirectory,
|
||||
);
|
||||
this.libraryDirectory =
|
||||
this.selectedDirectory;
|
||||
this.statusMessage =
|
||||
'Library directory saved. A scan will start automatically if the directory changed.';
|
||||
} catch (err) {
|
||||
this.statusMessage = `Failed to save directory: ${err}`;
|
||||
}
|
||||
};
|
||||
|
||||
private handleConcurrencyChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
const mode = String(e.detail.value);
|
||||
|
||||
SetScanConcurrency(mode)
|
||||
.then(() => {
|
||||
this.concurrencyMode = mode;
|
||||
this.statusMessage =
|
||||
'Storage type saved. Takes effect on next scan.';
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.statusMessage = `Failed to save storage type: ${err}`;
|
||||
});
|
||||
};
|
||||
|
||||
private handleSoftScan = async (): Promise<void> => {
|
||||
try {
|
||||
await Scan();
|
||||
} catch (err) {
|
||||
this.statusMessage = `Scan failed: ${err}`;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFullRescan = async (): Promise<void> => {
|
||||
const confirmed = confirm(
|
||||
'This will delete ALL library data including cover art and re-scan from scratch. Continue?',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await FullRescan();
|
||||
} catch (err) {
|
||||
this.statusMessage = `Full rescan failed: ${err}`;
|
||||
}
|
||||
};
|
||||
|
||||
private handleCopyMetrics = async (): Promise<void> => {
|
||||
if (!this.metrics) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
formatMetricsText(this.metrics),
|
||||
);
|
||||
this.copied = true;
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to copy metrics:',
|
||||
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,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// COMPUTED
|
||||
// ===================================================================
|
||||
|
||||
private get directoryChanged(): boolean {
|
||||
return (
|
||||
this.selectedDirectory !==
|
||||
this.libraryDirectory
|
||||
);
|
||||
}
|
||||
|
||||
private get hasRescanPhases(): boolean {
|
||||
if (!this.metrics) return false;
|
||||
|
||||
const m = this.metrics;
|
||||
|
||||
return (
|
||||
m.clearQueue > 0 ||
|
||||
m.clearDatabase > 0 ||
|
||||
m.clearCoverFiles > 0
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// RENDER
|
||||
// ===================================================================
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<h2>Settings</h2>
|
||||
|
||||
${this.renderThemeSection()}
|
||||
${this.renderLibrarySection()}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- 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>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Library section ---
|
||||
|
||||
private renderLibrarySection() {
|
||||
return html`
|
||||
<config-section
|
||||
heading="Library"
|
||||
description="Configure your music library location and scanning behaviour."
|
||||
>
|
||||
<config-field
|
||||
.schema=${{
|
||||
key: 'libraryDirectory',
|
||||
label: 'Library Directory',
|
||||
description:
|
||||
'The root directory containing your music files.',
|
||||
type: 'directory' as const,
|
||||
}}
|
||||
.value=${this.selectedDirectory}
|
||||
@config-browse=${this.handleDirectoryBrowse}
|
||||
></config-field>
|
||||
|
||||
${this.directoryChanged
|
||||
? html`
|
||||
<div class="save-row">
|
||||
<button
|
||||
class="btn-success"
|
||||
?disabled=${this.scanning}
|
||||
@click=${this.handleSaveDirectory}
|
||||
>
|
||||
Save Directory
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div class="scan-actions">
|
||||
<button
|
||||
class="btn-warning"
|
||||
?disabled=${this.scanning}
|
||||
@click=${this.handleSoftScan}
|
||||
>
|
||||
${this.scanning
|
||||
? 'Scanning...'
|
||||
: 'Soft Scan'}
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
?disabled=${this.scanning}
|
||||
@click=${this.handleFullRescan}
|
||||
>
|
||||
${this.scanning
|
||||
? 'Scanning...'
|
||||
: 'Full Rescan'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="status-bar ${this.scanning ? 'active' : ''}"
|
||||
>
|
||||
${this.statusMessage || 'Ready.'}
|
||||
</div>
|
||||
|
||||
${this.renderMetrics()}
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Metrics ---
|
||||
|
||||
private renderMetricRow(
|
||||
label: string,
|
||||
value: string,
|
||||
highlight = false,
|
||||
) {
|
||||
return html`
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">${label}</span>
|
||||
<span
|
||||
class="metric-value ${highlight ? 'highlight' : ''}"
|
||||
>${value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMetrics() {
|
||||
const m = this.metrics;
|
||||
|
||||
if (!m) return nothing;
|
||||
|
||||
const formatEntries = Object.entries(
|
||||
m.formatExtraction ?? {},
|
||||
).sort(([, a], [, b]) => b - a);
|
||||
|
||||
const pureDb = Math.max(
|
||||
0,
|
||||
m.batchCommits - m.coverArtSave,
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="metrics-wrapper">
|
||||
<div class="metrics-header">
|
||||
<p class="metrics-title">
|
||||
Scan Results
|
||||
</p>
|
||||
<button
|
||||
class="btn-ghost ${this.copied ? 'copied' : ''}"
|
||||
@click=${this.handleCopyMetrics}
|
||||
>
|
||||
${this.copied
|
||||
? 'Copied!'
|
||||
: 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.renderMetricRow('Total', fmtNs(m.total), true)}
|
||||
|
||||
<details class="root" open>
|
||||
<summary>File Counts</summary>
|
||||
<div class="counts-grid">
|
||||
<span class="count-label"
|
||||
>Added</span
|
||||
>
|
||||
<span class="count-value"
|
||||
>${m.added}</span
|
||||
>
|
||||
<span class="count-label"
|
||||
>Updated</span
|
||||
>
|
||||
<span class="count-value"
|
||||
>${m.updated}</span
|
||||
>
|
||||
<span class="count-label"
|
||||
>Skipped</span
|
||||
>
|
||||
<span class="count-value"
|
||||
>${m.skipped}</span
|
||||
>
|
||||
<span class="count-label"
|
||||
>Removed</span
|
||||
>
|
||||
<span class="count-value"
|
||||
>${m.removed}</span
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
${this.hasRescanPhases
|
||||
? html`
|
||||
<details class="root" open>
|
||||
<summary>
|
||||
Clear Phases
|
||||
</summary>
|
||||
${this.renderMetricRow('Clear Queue', fmtNs(m.clearQueue))}
|
||||
${this.renderMetricRow('Clear Database', fmtNs(m.clearDatabase))}
|
||||
${this.renderMetricRow('Clear Cover Files', fmtNs(m.clearCoverFiles))}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${this.renderMetricRow('Load Existing Files', fmtNs(m.loadExisting))}
|
||||
${this.renderMetricRow('Directory Walk', fmtNs(m.walkDuration))}
|
||||
|
||||
<details class="root" open>
|
||||
<summary>
|
||||
Metadata Extraction —
|
||||
${fmtNs(m.extractionWallClock)}
|
||||
wall-clock
|
||||
</summary>
|
||||
<p class="metric-note">
|
||||
Per-format and per-operation times
|
||||
are cumulative across
|
||||
${Object.values(
|
||||
m.formatCount ?? {},
|
||||
).reduce((a, b) => a + b, 0)}
|
||||
files
|
||||
</p>
|
||||
|
||||
${formatEntries.length > 0
|
||||
? html`
|
||||
<details open>
|
||||
<summary>
|
||||
By Format
|
||||
</summary>
|
||||
${formatEntries.map(
|
||||
([ext, ms]) =>
|
||||
this.renderMetricRow(
|
||||
`${ext} (${m.formatCount?.[ext] ?? 0} files)`,
|
||||
fmtMs(
|
||||
ms,
|
||||
),
|
||||
),
|
||||
)}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<details>
|
||||
<summary>By Operation</summary>
|
||||
${this.renderMetricRow('Tag Extraction', fmtNs(m.tagExtraction))}
|
||||
${this.renderMetricRow('Duration Extraction', fmtNs(m.durationExtraction))}
|
||||
</details>
|
||||
</details>
|
||||
|
||||
<details class="root" open>
|
||||
<summary>
|
||||
Database Writes —
|
||||
${fmtNs(m.dbWritesWallClock)}
|
||||
wall-clock
|
||||
</summary>
|
||||
${this.renderMetricRow('Batch Commits', fmtNs(m.batchCommits))}
|
||||
${this.renderMetricRow('Pure DB Operations', fmtNs(pureDb))}
|
||||
${this.renderMetricRow('Save Cover Originals', fmtNs(m.coverArtSave))}
|
||||
</details>
|
||||
|
||||
<details class="root" open>
|
||||
<summary>
|
||||
Thumbnail Generation —
|
||||
${fmtNs(m.thumbnailWallClock)}
|
||||
wall-clock
|
||||
</summary>
|
||||
<p class="metric-note">
|
||||
Generated concurrently; cumulative
|
||||
CPU time may exceed wall-clock
|
||||
</p>
|
||||
${this.renderMetricRow('Cumulative CPU Time', fmtNs(m.thumbnailGeneration))}
|
||||
${this.renderMetricRow('Small (_sm)', fmtNs(m.thumbnailSmall))}
|
||||
${this.renderMetricRow('Medium (_md)', fmtNs(m.thumbnailMedium))}
|
||||
${this.renderMetricRow('Large (_lg)', fmtNs(m.thumbnailLarge))}
|
||||
</details>
|
||||
|
||||
${this.renderMetricRow('Orphan Cleanup', fmtNs(m.orphanCleanup))}
|
||||
${this.renderMetricRow('Post-Scan Variants', fmtNs(m.postScanVariants))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'config-page': ConfigPage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
/**
|
||||
* A visual grouping wrapper for config fields.
|
||||
* Renders a heading, optional description, and a slot for fields.
|
||||
*/
|
||||
@customElement('config-section')
|
||||
export class ConfigSection extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
border: 1px solid var(--yj-border-subtle, #333);
|
||||
border-radius: 6px;
|
||||
padding: 1.25em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.25em;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8em;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
heading = '';
|
||||
|
||||
@property({ type: String })
|
||||
description = '';
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="section">
|
||||
<h3>${this.heading}</h3>
|
||||
${this.description
|
||||
? html`<p class="description">
|
||||
${this.description}
|
||||
</p>`
|
||||
: nothing}
|
||||
<div class="fields">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'config-section': ConfigSection;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export class AlbumDropdown extends LitElement {
|
||||
}
|
||||
|
||||
.album-dropdown {
|
||||
background-color: #343a40;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
@@ -81,7 +81,7 @@ export class AlbumDropdown extends LitElement {
|
||||
height: 0;
|
||||
border-left: 9px solid transparent;
|
||||
border-right: 9px solid transparent;
|
||||
border-bottom: 8px solid #343a40;
|
||||
border-bottom: 8px solid var(--yj-bg-elevated, #343a40);
|
||||
}
|
||||
|
||||
.dropdown-tracks {
|
||||
@@ -103,11 +103,9 @@ export class AlbumDropdown extends LitElement {
|
||||
}
|
||||
|
||||
.track-row:hover {
|
||||
background-color: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.05
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,13 +119,11 @@ export class AlbumDropdown extends LitElement {
|
||||
}
|
||||
|
||||
.track-row.active {
|
||||
background-color: rgba(
|
||||
255,
|
||||
212,
|
||||
59,
|
||||
0.1
|
||||
background-color: var(
|
||||
--yj-accent-bg,
|
||||
rgba(255, 212, 59, 0.1)
|
||||
);
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-row.selected.active {
|
||||
@@ -140,14 +136,14 @@ export class AlbumDropdown extends LitElement {
|
||||
}
|
||||
|
||||
.track-number {
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
min-width: 22px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -156,7 +152,7 @@ export class AlbumDropdown extends LitElement {
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -203,21 +203,21 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.album-card.selected {
|
||||
outline: 2px solid #ffd43b;
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.album-card.expanded {
|
||||
background-color: #343a40;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.album-card:focus-visible {
|
||||
outline: 2px solid #ffd43b;
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ export class CoverGrid extends LitElement {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: #282828;
|
||||
background-color: var(--yj-bg-surface, #282828);
|
||||
transition: scale 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -263,8 +263,8 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
.select-toggle.checked {
|
||||
opacity: 1;
|
||||
background-color: #ffd43b;
|
||||
border-color: #ffd43b;
|
||||
background-color: var(--yj-accent, #ffd43b);
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
@@ -289,10 +289,10 @@ export class CoverGrid extends LitElement {
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#404040 0%,
|
||||
#282828 100%
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828) 100%
|
||||
);
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--placeholder-font, 48px);
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ export class CoverGrid extends LitElement {
|
||||
.album-name {
|
||||
font-size: var(--album-name-font, 14px);
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -318,7 +318,7 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
.artist-name {
|
||||
font-size: var(--artist-name-font, 12px);
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -326,7 +326,7 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
.album-year {
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -338,7 +338,7 @@ export class CoverGrid extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -347,7 +347,7 @@ export class CoverGrid extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -364,8 +364,8 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #444;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@@ -377,16 +377,14 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
.context-menu-panel wa-dropdown-item {
|
||||
--wa-color-text-normal: #fff;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-menu-panel wa-dropdown-item:hover {
|
||||
background-color: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.1
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ export class LibraryManager extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5em;
|
||||
color: #e9ecef;
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -251,13 +251,13 @@ export class LibraryManager extends LitElement {
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
color: var(--yj-text-primary, #f8f9fa);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2em;
|
||||
padding: 1.25em;
|
||||
background: #2b3035;
|
||||
background: var(--yj-bg-surface, #2b3035);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ export class LibraryManager extends LitElement {
|
||||
margin: 0 0 0.75em 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: #dee2e6;
|
||||
color: var(--yj-text-primary, #dee2e6);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -282,7 +282,7 @@ export class LibraryManager extends LitElement {
|
||||
.section-description {
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.85em;
|
||||
color: #868e96;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -296,10 +296,10 @@ export class LibraryManager extends LitElement {
|
||||
.directory-path {
|
||||
flex: 1;
|
||||
padding: 0.5em 0.75em;
|
||||
background: #1a1d20;
|
||||
border: 1px solid #495057;
|
||||
background: var(--yj-bg-elevated, #1a1d20);
|
||||
border: 1px solid var(--yj-bg-overlay, #495057);
|
||||
border-radius: 4px;
|
||||
color: #adb5bd;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
font-size: 0.85em;
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
@@ -309,7 +309,7 @@ export class LibraryManager extends LitElement {
|
||||
}
|
||||
|
||||
.directory-path.has-value {
|
||||
color: #e9ecef;
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -329,57 +329,57 @@ export class LibraryManager extends LitElement {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4263eb;
|
||||
background: var(--yj-info, #4263eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #3b5bdb;
|
||||
background: var(--yj-info-hover, #3b5bdb);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #2f9e44;
|
||||
background: var(--yj-success, #2f9e44);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #2b8a3e;
|
||||
background: var(--yj-success-hover, #2b8a3e);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #e8590c;
|
||||
background: var(--yj-warning, #e8590c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d9480f;
|
||||
background: var(--yj-warning-hover, #d9480f);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e03131;
|
||||
background: var(--yj-error, #e03131);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c92a2a;
|
||||
background: var(--yj-error-hover, #c92a2a);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: #868e96;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
padding: 0.3em 0.75em;
|
||||
font-size: 0.75em;
|
||||
border: 1px solid #495057;
|
||||
border: 1px solid var(--yj-bg-overlay, #495057);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #495057;
|
||||
color: #e9ecef;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.btn-ghost.copied {
|
||||
border-color: #2f9e44;
|
||||
color: #2f9e44;
|
||||
border-color: var(--yj-success, #2f9e44);
|
||||
color: var(--yj-success, #2f9e44);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
@@ -390,16 +390,16 @@ export class LibraryManager extends LitElement {
|
||||
}
|
||||
|
||||
.setting-row label {
|
||||
color: #adb5bd;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
.setting-row select {
|
||||
padding: 0.4em 0.6em;
|
||||
background: #1a1d20;
|
||||
border: 1px solid #495057;
|
||||
background: var(--yj-bg-elevated, #1a1d20);
|
||||
border: 1px solid var(--yj-bg-overlay, #495057);
|
||||
border-radius: 4px;
|
||||
color: #e9ecef;
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
@@ -408,12 +408,12 @@ export class LibraryManager extends LitElement {
|
||||
|
||||
.setting-row select:focus {
|
||||
outline: none;
|
||||
border-color: #4263eb;
|
||||
border-color: var(--yj-info, #4263eb);
|
||||
}
|
||||
|
||||
.setting-row select option {
|
||||
background: #2b3035;
|
||||
color: #e9ecef;
|
||||
background: var(--yj-bg-surface, #2b3035);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
}
|
||||
|
||||
.scan-actions {
|
||||
@@ -425,15 +425,15 @@ export class LibraryManager extends LitElement {
|
||||
.status-bar {
|
||||
margin-top: 1.5em;
|
||||
padding: 0.75em 1em;
|
||||
background: #1a1d20;
|
||||
background: var(--yj-bg-elevated, #1a1d20);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #868e96;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.status-bar.active {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
/* --- Metrics tree --- */
|
||||
@@ -453,7 +453,7 @@ export class LibraryManager extends LitElement {
|
||||
cursor: pointer;
|
||||
padding: 0.25em 0;
|
||||
font-size: 0.85em;
|
||||
color: #ced4da;
|
||||
color: var(--yj-text-secondary, #ced4da);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -484,21 +484,21 @@ export class LibraryManager extends LitElement {
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #adb5bd;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #e9ecef;
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value.highlight {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.metric-note {
|
||||
color: #868e96;
|
||||
color: var(--yj-text-tertiary, #868e96);
|
||||
font-size: 0.75em;
|
||||
font-style: italic;
|
||||
padding-left: 1.35em;
|
||||
@@ -513,11 +513,11 @@ export class LibraryManager extends LitElement {
|
||||
}
|
||||
|
||||
.count-label {
|
||||
color: #adb5bd;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
}
|
||||
|
||||
.count-value {
|
||||
color: #e9ecef;
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
font-family: monospace;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -48,14 +48,14 @@ export class NowPlaying extends LitElement {
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
background-color: var(--yj-bg-base, #000);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover-placeholder wa-icon {
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class NowPlaying extends LitElement {
|
||||
|
||||
.track-artist {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -96,7 +96,7 @@ export class NowPlaying extends LitElement {
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--yj-text-tertiary, #6c757d);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ export class PlaylistPicker extends LitElement {
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #444;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@@ -49,17 +49,17 @@ export class PlaylistPicker extends LitElement {
|
||||
|
||||
.picker-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: #fff;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-panel wa-dropdown-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: #555;
|
||||
background: var(--yj-border-subtle, #555);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export class PlaylistPicker extends LitElement {
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
background: #2a2d30;
|
||||
border: 1px solid #555;
|
||||
background: var(--yj-bg-surface, #2b3035);
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
@@ -82,11 +82,11 @@ export class PlaylistPicker extends LitElement {
|
||||
}
|
||||
|
||||
.create-form input:focus {
|
||||
border-color: #ffd43b;
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.create-form input::placeholder {
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
@@ -96,10 +96,10 @@ export class PlaylistPicker extends LitElement {
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
background: #495057;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -111,23 +111,23 @@ export class PlaylistPicker extends LitElement {
|
||||
}
|
||||
|
||||
.button-row button.primary {
|
||||
background: #ffd43b;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.button-row button.primary:hover {
|
||||
background: #ffe066;
|
||||
background: var(--yj-accent-hover, #ffe066);
|
||||
}
|
||||
|
||||
.button-row button.primary:disabled {
|
||||
background: #665a1e;
|
||||
color: #888;
|
||||
background: var(--yj-accent-muted, #665a1e);
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 8px 12px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -205,21 +205,21 @@ export class PlaylistView
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.new-playlist-button {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -230,8 +230,8 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.new-playlist-button:hover {
|
||||
border-color: #ffd43b;
|
||||
color: #ffd43b;
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
@@ -239,16 +239,16 @@ export class PlaylistView
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
flex: 1;
|
||||
background: #2a2d30;
|
||||
border: 1px solid #555;
|
||||
background: var(--yj-bg-surface, #2b3035);
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
@@ -256,18 +256,18 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.create-form input:focus {
|
||||
border-color: #ffd43b;
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.create-form input::placeholder {
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.create-form button {
|
||||
background: #495057;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -275,21 +275,21 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.create-form button:hover {
|
||||
background: #5a6268;
|
||||
background: var(--yj-bg-overlay, #5a6268);
|
||||
}
|
||||
|
||||
.create-form button.primary {
|
||||
background: #ffd43b;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.create-form button.primary:hover {
|
||||
background: #ffe066;
|
||||
background: var(--yj-accent-hover, #ffe066);
|
||||
}
|
||||
|
||||
.create-form button.primary:disabled {
|
||||
background: #665a1e;
|
||||
color: #888;
|
||||
background: var(--yj-accent-muted, #665a1e);
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ export class PlaylistView
|
||||
|
||||
.playlist-item {
|
||||
border-bottom: 1px solid
|
||||
rgba(255, 255, 255, 0.05);
|
||||
var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.playlist-header {
|
||||
@@ -316,18 +316,18 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.playlist-header:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.playlist-item.drag-over > .playlist-header {
|
||||
background-color: rgba(255, 212, 59, 0.15);
|
||||
outline: 1px dashed #ffd43b;
|
||||
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
|
||||
outline: 1px dashed var(--yj-accent, #ffd43b);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
@@ -338,13 +338,13 @@ export class PlaylistView
|
||||
|
||||
.playlist-icon {
|
||||
font-size: 18px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -353,7 +353,7 @@ export class PlaylistView
|
||||
|
||||
.track-count {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -370,9 +370,9 @@ export class PlaylistView
|
||||
|
||||
.play-all-button {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -383,8 +383,8 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.play-all-button:hover {
|
||||
border-color: #ffd43b;
|
||||
color: #ffd43b;
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-item {
|
||||
@@ -396,20 +396,20 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.track-item.selected {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-item.active {
|
||||
background-color: rgba(255, 212, 59, 0.1);
|
||||
color: #ffd43b;
|
||||
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-item.selected.active {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-item.phantom {
|
||||
@@ -424,7 +424,7 @@ export class PlaylistView
|
||||
.phantom-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: #e67700;
|
||||
color: var(--yj-warning, #e67700);
|
||||
background: rgba(230, 119, 0, 0.15);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
@@ -438,7 +438,7 @@ export class PlaylistView
|
||||
|
||||
.tracks-empty {
|
||||
padding: 12px 0;
|
||||
color: #666;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ export class PlaylistView
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -456,7 +456,7 @@ export class PlaylistView
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -474,8 +474,8 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #444;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@@ -484,7 +484,7 @@ export class PlaylistView
|
||||
|
||||
.context-menu-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: #fff;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -512,10 +512,10 @@ export class PlaylistView
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
background: #2a2d30;
|
||||
border: 1px solid #ffd43b;
|
||||
background: var(--yj-bg-surface, #2b3035);
|
||||
border: 1px solid var(--yj-accent, #ffd43b);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
@@ -525,9 +525,9 @@ export class PlaylistView
|
||||
|
||||
.import-button {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -538,8 +538,8 @@ export class PlaylistView
|
||||
}
|
||||
|
||||
.import-button:hover {
|
||||
border-color: #ffd43b;
|
||||
color: #ffd43b;
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export class QueuePanel
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
background-color: #212529;
|
||||
background-color: var(--yj-bg-surface, #212529);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class QueuePanel
|
||||
--queue-width,
|
||||
${unsafeCSS(DEFAULT_WIDTH)}px
|
||||
);
|
||||
border-left: 1px solid #333;
|
||||
border-left: 1px solid var(--yj-border-subtle, #333);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
@@ -180,7 +180,7 @@ export class QueuePanel
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--yj-text-tertiary, #6c757d);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
@@ -196,7 +196,7 @@ export class QueuePanel
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -223,11 +223,11 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
.header-action-button:hover {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.header-action-button:disabled {
|
||||
color: #555;
|
||||
color: var(--yj-border-subtle, #555);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export class QueuePanel
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid
|
||||
rgba(255, 255, 255, 0.05);
|
||||
var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
@@ -255,30 +255,30 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.track-item.selected {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-item.active {
|
||||
background-color: rgba(255, 212, 59, 0.1);
|
||||
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
|
||||
}
|
||||
|
||||
.track-item.selected.active {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-position {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-item.active .track-position {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-details {
|
||||
@@ -297,12 +297,12 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
.track-item.active .track-title {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 11px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -311,7 +311,7 @@ export class QueuePanel
|
||||
.remove-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
@@ -325,11 +325,11 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
color: #ff6b6b;
|
||||
color: var(--yj-error, #ff6b6b);
|
||||
}
|
||||
|
||||
.panel-content.drag-over {
|
||||
outline: 2px dashed #ffd43b;
|
||||
outline: 2px dashed var(--yj-accent, #ffd43b);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ export class QueuePanel
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
border-bottom: 1px solid
|
||||
rgba(255, 212, 59, 0.2);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export class QueuePanel
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: #ffd43b;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
border-radius: 1px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
@@ -368,7 +368,7 @@ export class QueuePanel
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: #ffd43b;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
border-radius: 1px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
@@ -380,7 +380,7 @@ export class QueuePanel
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: #b3b3b3;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -394,8 +394,8 @@ export class QueuePanel
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #444;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@@ -404,7 +404,7 @@ export class QueuePanel
|
||||
|
||||
.context-menu-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: #fff;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
|
||||
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
|
||||
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 120;
|
||||
@@ -21,7 +23,7 @@ export class AppSidebar extends LitElement {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background-color: #212529;
|
||||
background-color: var(--yj-bg-surface, #212529);
|
||||
min-width: ${MIN_WIDTH}px;
|
||||
max-width: ${MAX_WIDTH}px;
|
||||
}
|
||||
@@ -40,7 +42,7 @@ export class AppSidebar extends LitElement {
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--yj-text-tertiary, #6c757d);
|
||||
}
|
||||
|
||||
ul {
|
||||
@@ -50,19 +52,28 @@ export class AppSidebar extends LitElement {
|
||||
}
|
||||
|
||||
li {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
border-radius: 5px;
|
||||
padding: 0.5em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
li wa-icon {
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background-color: #343a40;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
}
|
||||
|
||||
li.active {
|
||||
background-color: #495057;
|
||||
background-color: var(--yj-bg-overlay, #495057);
|
||||
}
|
||||
|
||||
li p {
|
||||
@@ -73,8 +84,8 @@ export class AppSidebar extends LitElement {
|
||||
}
|
||||
|
||||
li.drag-hover {
|
||||
background-color: rgba(255, 212, 59, 0.15);
|
||||
outline: 1px dashed #ffd43b;
|
||||
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
|
||||
outline: 1px dashed var(--yj-accent, #ffd43b);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
`;
|
||||
@@ -101,12 +112,13 @@ export class AppSidebar extends LitElement {
|
||||
> | null = null;
|
||||
|
||||
private navItems: NavItem[] = [
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'libraries', label: 'Libraries' },
|
||||
{ id: 'playlists', label: 'Playlists' },
|
||||
{ id: 'artists', label: 'Artists' },
|
||||
{ id: 'albums', label: 'Albums' },
|
||||
{ id: 'tracks', label: 'Tracks' },
|
||||
{ id: 'home', label: 'Home', icon: 'house' },
|
||||
{ id: 'libraries', label: 'Libraries', icon: 'folder-open' },
|
||||
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
];
|
||||
|
||||
override connectedCallback() {
|
||||
@@ -179,6 +191,9 @@ export class AppSidebar extends LitElement {
|
||||
@drop=${(e: DragEvent) =>
|
||||
this.onNavDrop(e)}
|
||||
>
|
||||
<wa-icon
|
||||
name=${item.icon}
|
||||
></wa-icon>
|
||||
<p>${item.label}</p>
|
||||
</li>
|
||||
`;
|
||||
|
||||
@@ -63,14 +63,14 @@ export class TrackInfo extends LitElement {
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #2a2d30;
|
||||
background-color: var(--yj-bg-elevated, #2a2d30);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover-placeholder wa-icon {
|
||||
color: #666;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export class TrackInfo extends LitElement {
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -93,7 +93,7 @@ export class TrackInfo extends LitElement {
|
||||
|
||||
.secondary {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -101,7 +101,7 @@ export class TrackInfo extends LitElement {
|
||||
|
||||
.duration {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -252,8 +252,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #666;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border-bottom: 1px solid var(--yj-text-tertiary, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
width: 1px;
|
||||
cursor: col-resize;
|
||||
pointer-events: auto;
|
||||
background-color: #444;
|
||||
background-color: var(--yj-border, #444);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
|
||||
.col-resize-handle:hover,
|
||||
.col-resize-handle.active {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--yj-text-tertiary, #6c757d);
|
||||
}
|
||||
|
||||
lit-virtualizer {
|
||||
@@ -307,7 +307,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
@@ -324,23 +324,23 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
}
|
||||
|
||||
.track-row:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.track-row.selected {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-row.active {
|
||||
background-color: rgba(255, 212, 59, 0.1);
|
||||
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
|
||||
}
|
||||
|
||||
.track-row.active {
|
||||
color: #ffd43b;
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.track-row.selected.active {
|
||||
background-color: rgba(100, 160, 255, 0.15);
|
||||
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||
}
|
||||
|
||||
.track-name {
|
||||
@@ -363,8 +363,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
background-color: #343a40;
|
||||
border: 1px solid #444;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@@ -376,12 +376,12 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
}
|
||||
|
||||
.context-menu-panel wa-dropdown-item {
|
||||
--wa-color-text-normal: #fff;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-menu-panel wa-dropdown-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
|
||||
@@ -48,6 +48,9 @@ export const Events = {
|
||||
PlaylistTracksChanged: "PlaylistTracksChanged",
|
||||
PlaylistsRestored: "PlaylistsRestored",
|
||||
|
||||
// Config events
|
||||
ThemeConfigChanged: "ThemeConfigChanged",
|
||||
|
||||
// Library events
|
||||
LibraryScanStarted: "LibraryScanStarted",
|
||||
LibraryScanComplete: "LibraryScanComplete",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'htmx.org/dist/htmx.js'
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { Scan } from '@go/library/Library';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DirectoryPicker: typeof DirectoryPicker;
|
||||
Scan: typeof Scan;
|
||||
selectLibraryDirectory: (pElement: HTMLInputElement) => void;
|
||||
scanLibrary: (button: HTMLButtonElement) => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.DirectoryPicker = DirectoryPicker;
|
||||
window.Scan = Scan;
|
||||
|
||||
window.selectLibraryDirectory = (pElement: HTMLInputElement) => {
|
||||
window.DirectoryPicker()
|
||||
.then((result) => {
|
||||
if (result.length !== 0) {
|
||||
pElement.value = result;
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('error with directory picker: ' + err);
|
||||
});
|
||||
};
|
||||
|
||||
window.scanLibrary = (button: HTMLButtonElement) => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Scanning...';
|
||||
window.Scan()
|
||||
.then(() => {
|
||||
button.textContent = 'Scan Library';
|
||||
button.disabled = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('error scanning library: ' + err);
|
||||
button.textContent = 'Scan Library';
|
||||
button.disabled = false;
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<link href="./config.css" rel="stylesheet" />
|
||||
<title>yellowjacket - config</title>
|
||||
</head>
|
||||
<script src="./config.ts" type="module"></script>
|
||||
|
||||
<body>
|
||||
<header class="container">
|
||||
<hgroup>
|
||||
<a href="/index.html">
|
||||
<h1>YellowJacket</h1>
|
||||
</a>
|
||||
</hgroup>
|
||||
</header>
|
||||
<hr />
|
||||
<main class="container">
|
||||
<div hx-get="/config" hx-trigger="load"></div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { ThemeState, BackgroundShade } from '../theme-store';
|
||||
import { themeStore } from '../theme-store';
|
||||
|
||||
/**
|
||||
* ThemeController connects a Lit component to the ThemeStore.
|
||||
*
|
||||
* Most components do not need this because CSS custom properties
|
||||
* cascade into Shadow DOM automatically. Use this controller only
|
||||
* in components that need to read or change theme values (e.g. the
|
||||
* config page colour picker).
|
||||
*/
|
||||
export class ThemeController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE HOOKS
|
||||
// ===================================================================
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = themeStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESSORS
|
||||
// ===================================================================
|
||||
|
||||
get state(): Readonly<ThemeState> {
|
||||
return themeStore.getState();
|
||||
}
|
||||
|
||||
get accentColor(): string {
|
||||
return this.state.accentColor;
|
||||
}
|
||||
|
||||
get backgroundShade(): BackgroundShade {
|
||||
return this.state.backgroundShade;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// ===================================================================
|
||||
|
||||
async setAccentColor(color: string): Promise<void> {
|
||||
await themeStore.setAccentColor(color);
|
||||
}
|
||||
|
||||
async setBackgroundShade(
|
||||
shade: BackgroundShade,
|
||||
): Promise<void> {
|
||||
await themeStore.setBackgroundShade(shade);
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,6 @@ export { PlayerController } from './controllers/player-controller';
|
||||
export { queueStore } from './queue-store';
|
||||
export type { QueueState, QueueTrack, RepeatMode } from './queue-store';
|
||||
export { QueueController } from './controllers/queue-controller';
|
||||
export { themeStore } from './theme-store';
|
||||
export type { ThemeState, BackgroundShade } from './theme-store';
|
||||
export { ThemeController } from './controllers/theme-controller';
|
||||
@@ -0,0 +1,291 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
GetThemeAccentColor,
|
||||
GetThemeBackgroundShade,
|
||||
SetThemeAccentColor,
|
||||
SetThemeBackgroundShade,
|
||||
} from '@go/config/Config';
|
||||
import { Events } from '../events';
|
||||
|
||||
export type BackgroundShade = 'darker' | 'dark' | 'light';
|
||||
|
||||
export interface ThemeState {
|
||||
accentColor: string;
|
||||
backgroundShade: BackgroundShade;
|
||||
}
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
/**
|
||||
* Shade palettes keyed by BackgroundShade.
|
||||
* Each defines the base grayscale ramp used throughout the UI.
|
||||
*/
|
||||
interface ShadePalette {
|
||||
bgBase: string;
|
||||
bgSurface: string;
|
||||
bgElevated: string;
|
||||
bgOverlay: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
border: string;
|
||||
borderSubtle: string;
|
||||
hoverOverlay: string;
|
||||
selectionBg: string;
|
||||
}
|
||||
|
||||
const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
|
||||
darker: {
|
||||
bgBase: '#000000',
|
||||
bgSurface: '#121212',
|
||||
bgElevated: '#1e1e1e',
|
||||
bgOverlay: '#2a2a2a',
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#b3b3b3',
|
||||
textTertiary: '#888888',
|
||||
border: '#333333',
|
||||
borderSubtle: '#222222',
|
||||
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
||||
selectionBg: 'rgba(100, 160, 255, 0.15)',
|
||||
},
|
||||
dark: {
|
||||
bgBase: '#000000',
|
||||
bgSurface: '#212529',
|
||||
bgElevated: '#343a40',
|
||||
bgOverlay: '#495057',
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#b3b3b3',
|
||||
textTertiary: '#888888',
|
||||
border: '#444444',
|
||||
borderSubtle: '#333333',
|
||||
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
||||
selectionBg: 'rgba(100, 160, 255, 0.15)',
|
||||
},
|
||||
light: {
|
||||
bgBase: '#ffffff',
|
||||
bgSurface: '#f8f9fa',
|
||||
bgElevated: '#e9ecef',
|
||||
bgOverlay: '#dee2e6',
|
||||
textPrimary: '#212529',
|
||||
textSecondary: '#495057',
|
||||
textTertiary: '#868e96',
|
||||
border: '#ced4da',
|
||||
borderSubtle: '#dee2e6',
|
||||
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
|
||||
selectionBg: 'rgba(100, 160, 255, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
/** Parse a hex colour string to RGB components. */
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
let cleaned = hex.replace('#', '');
|
||||
|
||||
if (cleaned.length === 3) {
|
||||
cleaned =
|
||||
cleaned[0]! + cleaned[0]! +
|
||||
cleaned[1]! + cleaned[1]! +
|
||||
cleaned[2]! + cleaned[2]!;
|
||||
}
|
||||
|
||||
return {
|
||||
r: parseInt(cleaned.slice(0, 2), 16),
|
||||
g: parseInt(cleaned.slice(2, 4), 16),
|
||||
b: parseInt(cleaned.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mix a colour towards white by a fraction (0..1). */
|
||||
function lighten(hex: string, amount: number): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const lr = Math.round(r + (255 - r) * amount);
|
||||
const lg = Math.round(g + (255 - g) * amount);
|
||||
const lb = Math.round(b + (255 - b) * amount);
|
||||
|
||||
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Mix a colour towards black by a fraction (0..1). */
|
||||
function darken(hex: string, amount: number): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const dr = Math.round(r * (1 - amount));
|
||||
const dg = Math.round(g * (1 - amount));
|
||||
const db = Math.round(b * (1 - amount));
|
||||
|
||||
return `#${dr.toString(16).padStart(2, '0')}${dg.toString(16).padStart(2, '0')}${db.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the full set of CSS custom properties from accent + shade.
|
||||
*/
|
||||
function deriveThemeVariables(
|
||||
accent: string,
|
||||
shade: BackgroundShade,
|
||||
): Record<string, string> {
|
||||
const palette = SHADE_PALETTES[shade];
|
||||
const { r, g, b } = hexToRgb(accent);
|
||||
|
||||
return {
|
||||
// Background ramp
|
||||
'--yj-bg-base': palette.bgBase,
|
||||
'--yj-bg-surface': palette.bgSurface,
|
||||
'--yj-bg-elevated': palette.bgElevated,
|
||||
'--yj-bg-overlay': palette.bgOverlay,
|
||||
|
||||
// Accent ramp
|
||||
'--yj-accent': accent,
|
||||
'--yj-accent-hover': lighten(accent, 0.15),
|
||||
'--yj-accent-muted': darken(accent, 0.5),
|
||||
'--yj-accent-bg': `rgba(${r}, ${g}, ${b}, 0.1)`,
|
||||
'--yj-accent-bg-strong': `rgba(${r}, ${g}, ${b}, 0.15)`,
|
||||
|
||||
// Text
|
||||
'--yj-text-primary': palette.textPrimary,
|
||||
'--yj-text-secondary': palette.textSecondary,
|
||||
'--yj-text-tertiary': palette.textTertiary,
|
||||
|
||||
// Borders
|
||||
'--yj-border': palette.border,
|
||||
'--yj-border-subtle': palette.borderSubtle,
|
||||
|
||||
// Interactive overlays
|
||||
'--yj-hover-overlay': palette.hoverOverlay,
|
||||
'--yj-selection-bg': palette.selectionBg,
|
||||
|
||||
// Semantic colours (fixed across themes)
|
||||
'--yj-success': '#2f9e44',
|
||||
'--yj-success-hover': '#2b8a3e',
|
||||
'--yj-warning': '#e8590c',
|
||||
'--yj-warning-hover': '#d9480f',
|
||||
'--yj-error': '#e03131',
|
||||
'--yj-error-hover': '#c92a2a',
|
||||
'--yj-info': '#4263eb',
|
||||
'--yj-info-hover': '#3b5bdb',
|
||||
};
|
||||
}
|
||||
|
||||
class ThemeStore {
|
||||
private state: ThemeState = {
|
||||
accentColor: '#ffd43b',
|
||||
backgroundShade: 'dark',
|
||||
};
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.initializeEventListeners();
|
||||
this.loadFromBackend();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WAILS EVENT BRIDGE
|
||||
// ===================================================================
|
||||
|
||||
private initializeEventListeners(): void {
|
||||
EventsOn(
|
||||
Events.ThemeConfigChanged,
|
||||
(data: {
|
||||
AccentColor: string;
|
||||
BackgroundShade: string;
|
||||
}) => {
|
||||
this.update({
|
||||
accentColor: data.AccentColor,
|
||||
backgroundShade:
|
||||
data.BackgroundShade as BackgroundShade,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async loadFromBackend(): Promise<void> {
|
||||
try {
|
||||
const [accent, shade] = await Promise.all([
|
||||
GetThemeAccentColor(),
|
||||
GetThemeBackgroundShade(),
|
||||
]);
|
||||
|
||||
this.update({
|
||||
accentColor: accent,
|
||||
backgroundShade: shade as BackgroundShade,
|
||||
});
|
||||
this.initialized = true;
|
||||
} catch {
|
||||
// Use defaults on failure, apply them so the UI has variables.
|
||||
this.applyVariables();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESS
|
||||
// ===================================================================
|
||||
|
||||
getState(): Readonly<ThemeState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// ===================================================================
|
||||
|
||||
async setAccentColor(color: string): Promise<void> {
|
||||
await SetThemeAccentColor(color);
|
||||
}
|
||||
|
||||
async setBackgroundShade(
|
||||
shade: BackgroundShade,
|
||||
): Promise<void> {
|
||||
await SetThemeBackgroundShade(shade);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SUBSCRIPTION SYSTEM
|
||||
// ===================================================================
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
private update(partial: Partial<ThemeState>): void {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.applyVariables();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((callback) => callback());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the full set of CSS custom properties to :root so every
|
||||
* component (including Shadow DOM) inherits them automatically.
|
||||
*/
|
||||
private applyVariables(): void {
|
||||
const vars = deriveThemeVariables(
|
||||
this.state.accentColor,
|
||||
this.state.backgroundShade,
|
||||
);
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
for (const [prop, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(prop, value);
|
||||
}
|
||||
|
||||
// Set the document color-scheme so native form controls
|
||||
// (select dropdowns, scrollbars, etc.) match the theme.
|
||||
root.style.colorScheme =
|
||||
this.state.backgroundShade === 'light'
|
||||
? 'light'
|
||||
: 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance.
|
||||
export const themeStore = new ThemeStore();
|
||||
Reference in new issue
Block a user