added color/ theme settings, new config window

This commit is contained in:
2026-02-20 14:43:35 -05:00
parent fa6506ed5d
commit 88c3736110
43 changed files with 2314 additions and 1207 deletions
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
+29 -14
View File
@@ -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 {
+3
View File
@@ -48,6 +48,9 @@ export const Events = {
PlaylistTracksChanged: "PlaylistTracksChanged",
PlaylistsRestored: "PlaylistsRestored",
// Config events
ThemeConfigChanged: "ThemeConfigChanged",
// Library events
LibraryScanStarted: "LibraryScanStarted",
LibraryScanComplete: "LibraryScanComplete",
-11
View File
@@ -1,11 +0,0 @@
*,
*::before,
*::after {
-webkit-user-select: none;
user-select: none;
}
body {
background-color: black;
color: white;
}
-42
View File
@@ -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;
});
};
-26
View File
@@ -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);
}
}
+3
View File
@@ -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';
+291
View File
@@ -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();