added color/ theme settings, new config window

This commit is contained in:
yonlu committed 2026-02-20 14:43:35 -05:00
1 parent fa6506ed5d
commit 88c3736110
43 files changed
+2314 -1207

No files matched your search

-1
View File
@@ -65,7 +65,6 @@ func NewYellowJacketApp(
}
yjApp.appConfig = appConfig
yjApp.assetHandler.RegisterHandler("/config", yjApp.appConfig)
// create frontendUtil
feUtil, err := frontendutil.NewFrontendUtil()
-15
View File
@@ -1,15 +0,0 @@
package config
import "yellowjacket/pkg/templcomp"
templ (c *Config) form() {
@templcomp.ToForm(c, templ.URL("/config"), "config")
}
templ (c *Config) formSubmitError(msg string) {
<strong>Error: { msg }</strong>
}
templ (c *Config) formSubmitSuccess() {
<p>Config saved</p>
}
-113
View File
@@ -1,113 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package config
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "yellowjacket/pkg/templcomp"
func (c *Config) form() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templcomp.ToForm(c, templ.URL("/config"), "config").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Config) formSubmitError(msg string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<strong>Error: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(msg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `config/config-form.templ`, Line: 10, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func (c *Config) formSubmitSuccess() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p>Config saved</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+120 -8
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path"
@@ -16,17 +15,17 @@ import (
"yellowjacket/backend/events"
"yellowjacket/backend/library"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
)
// Config represents the application configuration.
type Config struct {
ctx context.Context
logger *slog.Logger
serveMux *http.ServeMux
filePath string // required
Library *library.Config `form:"Library" schema:"library,required"`
Window *WindowConfig `toml:"Window"`
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
}
// NewConfig creates a new config by loading it from disk.
@@ -38,11 +37,9 @@ func NewConfig(logger *slog.Logger) (*Config, error) {
conf := &Config{
filePath: path.Join(confDir, "config.toml"),
serveMux: http.NewServeMux(),
}
conf.applyDefaults()
conf.logger = logger.WithGroup("config").With("config", conf)
conf.serveMux.HandleFunc("/", conf.handle)
if err := conf.Load(); err != nil {
return nil, fmt.Errorf("could not load config: %w", err)
@@ -67,8 +64,17 @@ func (c *Config) Validate() error {
}
}
if c.Theme != nil {
if err := c.Theme.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if configErrs != nil {
return fmt.Errorf("one or more config parts are invalid: %w", configErrs)
return fmt.Errorf(
"one or more config parts are invalid: %w",
configErrs,
)
}
return nil
@@ -148,6 +154,12 @@ func (c *Config) applyDefaults() {
if c.Library != nil {
c.Library.ApplyDefaults()
}
if c.Theme == nil {
c.Theme = &theme.Config{}
}
c.Theme.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
@@ -245,3 +257,103 @@ func (c *Config) SetScanConcurrency(mode string) error {
return nil
}
// GetThemeAccentColor returns the configured accent colour.
func (c *Config) GetThemeAccentColor() string {
if c.Theme == nil {
return theme.DefaultAccentColor
}
return c.Theme.AccentColor
}
// GetThemeBackgroundShade returns the configured background shade.
func (c *Config) GetThemeBackgroundShade() string {
if c.Theme == nil {
return string(theme.DefaultBackgroundShade)
}
return string(c.Theme.BackgroundShade)
}
// SetThemeAccentColor validates and saves a new accent colour.
func (c *Config) SetThemeAccentColor(
color string,
) error {
if c.Theme == nil {
c.Theme = &theme.Config{}
c.Theme.ApplyDefaults()
}
c.Theme.AccentColor = color
if err := c.Theme.Validate(); err != nil {
return fmt.Errorf(
"invalid theme accent color: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitThemeChanged()
c.logger.Info(
"theme accent color updated",
"color", color,
)
return nil
}
// SetThemeBackgroundShade validates and saves a new background shade.
func (c *Config) SetThemeBackgroundShade(
shade string,
) error {
if c.Theme == nil {
c.Theme = &theme.Config{}
c.Theme.ApplyDefaults()
}
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
if err := c.Theme.Validate(); err != nil {
return fmt.Errorf(
"invalid theme background shade: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitThemeChanged()
c.logger.Info(
"theme background shade updated",
"shade", shade,
)
return nil
}
// emitThemeChanged sends the ThemeConfigChanged event to the frontend.
func (c *Config) emitThemeChanged() {
if c.ctx == nil || c.Theme == nil {
return
}
runtime.EventsEmit(
c.ctx,
events.ThemeConfigChanged,
map[string]any{
"AccentColor": c.Theme.AccentColor,
"BackgroundShade": string(c.Theme.BackgroundShade),
},
)
}
-80
View File
@@ -1,80 +0,0 @@
package config
import (
"fmt"
"net/http"
"github.com/gorilla/schema"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
var formDecoder = schema.NewDecoder()
func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.serveMux.ServeHTTP(w, r)
}
func (c *Config) handle(w http.ResponseWriter, r *http.Request) {
c.logger.Debug("handling request from config http handler")
switch r.Method {
case http.MethodGet:
if err := c.form().Render(r.Context(), w); err != nil {
c.logger.Error("problem getting config html", "err", err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
case http.MethodPost:
if err := c.handleConfigPost(r); err != nil {
c.logger.Error("problem handling config post request", "err", err.Error())
renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w)
if renderErr != nil {
c.logger.Error("problem rendering error response", "err", renderErr.Error())
}
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := c.formSubmitSuccess().Render(r.Context(), w); err != nil {
c.logger.Error("problem rendering success response", "err", err.Error())
}
w.WriteHeader(http.StatusOK)
}
}
func (c *Config) handleConfigPost(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return fmt.Errorf("could not parse form data: %w", err)
}
var postedConfig Config
err := formDecoder.Decode(&postedConfig, r.PostForm)
if err != nil {
return fmt.Errorf("could not decode form data: %w", err)
}
c.logger.Debug("decoded config post form data", "postedConfig", postedConfig)
// Update local config and emit event for listeners
if postedConfig.Library != nil {
c.Library = postedConfig.Library
if c.ctx != nil {
runtime.EventsEmit(c.ctx, events.LibraryConfigChanged, map[string]any{
"DirectoryPath": string(c.Library.DirectoryPath),
})
}
}
if err := c.Save(); err != nil {
return fmt.Errorf("could not save posted config: %w", err)
}
return nil
}
+1
View File
@@ -55,6 +55,7 @@ const (
// Config events.
const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
)
// Playlist events.
-7
View File
@@ -1,7 +0,0 @@
package library
templ (d Directory) ToFormElement() {
<button type="button" onclick="selectLibraryDirectory(this.nextElementSibling)">Select</button>
<input type="text" name="library.directory" value={ d } readonly/>
<button type="button" onclick="scanLibrary(this)">Scan Library</button>
}
-53
View File
@@ -1,53 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package library
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func (d Directory) ToFormElement() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button type=\"button\" onclick=\"selectLibraryDirectory(this.nextElementSibling)\">Select</button> <input type=\"text\" name=\"library.directory\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library/config.templ`, Line: 5, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" readonly> <button type=\"button\" onclick=\"scanLibrary(this)\">Scan Library</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+80
View File
@@ -0,0 +1,80 @@
// Package theme manages visual theme configuration.
package theme
import (
"errors"
"fmt"
"regexp"
)
var (
errInvalidHexColor = errors.New("invalid hex color")
errUnknownBackgroundShade = errors.New("unknown background shade")
)
// hexColorRe matches 3- or 6-digit CSS hex colours (e.g. "#fff", "#ffd43b").
var hexColorRe = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// BackgroundShade controls the base grayscale palette.
type BackgroundShade string
// Valid BackgroundShade values.
const (
// BackgroundDarker is an OLED-friendly palette with true black.
BackgroundDarker BackgroundShade = "darker"
// BackgroundDark is the default dark palette.
BackgroundDark BackgroundShade = "dark"
// BackgroundLight is a light-mode palette.
BackgroundLight BackgroundShade = "light"
)
// Defaults.
const (
DefaultAccentColor = "#ffd43b"
DefaultBackgroundShade = BackgroundDark
)
// Config holds visual theme preferences.
type Config struct {
AccentColor string `toml:"AccentColor"`
BackgroundShade BackgroundShade `toml:"BackgroundShade"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *Config) ApplyDefaults() {
if c.AccentColor == "" {
c.AccentColor = DefaultAccentColor
}
if c.BackgroundShade == "" {
c.BackgroundShade = DefaultBackgroundShade
}
}
// Validate checks that all values are well-formed.
func (c *Config) Validate() error {
c.ApplyDefaults()
if !hexColorRe.MatchString(c.AccentColor) {
return fmt.Errorf(
"%w: %q",
errInvalidHexColor,
c.AccentColor,
)
}
switch c.BackgroundShade {
case BackgroundDarker, BackgroundDark, BackgroundLight:
// Valid.
default:
return fmt.Errorf(
"%w: %q",
errUnknownBackgroundShade,
c.BackgroundShade,
)
}
return nil
}
+10 -10
View File
@@ -10,8 +10,8 @@ html {
}
body {
background-color: black;
color: white;
background-color: var(--yj-bg-base, black);
color: var(--yj-text-primary, white);
margin: 0;
height: 100vh;
display: grid;
@@ -32,7 +32,7 @@ p {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #343a40;
background-color: var(--yj-bg-elevated, #343a40);
}
ul {
@@ -51,14 +51,14 @@ ul {
body div.sidebar {
grid-area: sidebar;
background-color: #212529;
background-color: var(--yj-bg-surface, #212529);
overflow: hidden;
}
.bottom-bar {
grid-area: bottom-bar;
padding: 0.25em;
background-color: #343a40;
background-color: var(--yj-bg-elevated, #343a40);
display: grid;
grid-template-columns: var(--now-playing-width, 200px) 1fr auto;
align-items: center;
@@ -75,7 +75,7 @@ body div.sidebar {
height: 3.5em;
min-height: 3.5em;
border-radius: 0.25em;
background-color: #ffd43b;
background-color: var(--yj-accent, #ffd43b);
}
#track-info {
@@ -114,12 +114,12 @@ body div.sidebar {
}
#queue-button:hover {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
#queue-button.drag-over {
color: #ffd43b;
outline: 2px dashed #ffd43b;
color: var(--yj-accent, #ffd43b);
outline: 2px dashed var(--yj-accent, #ffd43b);
outline-offset: -2px;
border-radius: 4px;
}
@@ -135,7 +135,7 @@ body div.sidebar {
flex: 1;
min-width: 0;
padding: 0.25em;
background-color: #212529;
background-color: var(--yj-bg-surface, #212529);
overflow: hidden;
}
-3
View File
@@ -15,9 +15,6 @@
<h1 class="title">YellowJacket</h1>
<h3 class="subtitle">Music how it was meant to bee.</h3>
</hgroup>
<a href="/src/pages/config/">
<img src="/src/assets/images/icons/ui/settings.svg" />
</a>
</header>
<div class="sidebar">
<app-sidebar></app-sidebar>
+8 -1
View File
@@ -6,12 +6,16 @@ import '@components/sidebar/app-sidebar.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/playlist-view/playlist-view.ts';
import '@components/library-manager/library-manager.ts';
import '@components/config-page/config-page.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { libraryStore } from '@store/library-store';
import { playlistStore } from '@store/playlist-store';
import { queueStore } from '@store/queue-store';
// Importing the theme store triggers initialization: it fetches the saved
// theme from the backend and applies CSS custom properties to :root.
import '@store/theme-store';
import {
hasTrackPayload,
getDragPayload,
@@ -46,8 +50,11 @@ document.addEventListener('navigate', (e: Event) => {
case 'libraries':
mainContent.innerHTML = '<library-manager></library-manager>';
break;
case 'settings':
mainContent.innerHTML = '<config-page></config-page>';
break;
default:
mainContent.innerHTML = `<div style="padding: 1em; color: #b3b3b3;">
mainContent.innerHTML = `<div style="padding: 1em; color: var(--yj-text-secondary, #b3b3b3);">
<p>Coming soon: ${view}</p>
</div>`;
}
-1
View File
@@ -8,7 +8,6 @@
"@awesome.me/webawesome": "^3.2.1",
"@lit-labs/signals": "^0.2.0",
"@lit-labs/virtualizer": "^2.1.1",
"htmx.org": "2.0.8",
"lit": "^3.2.1"
},
"devDependencies": {
@@ -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 &mdash;
${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 &mdash;
${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 &mdash;
${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;
}
+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();
-1
View File
@@ -17,7 +17,6 @@ export default defineConfig({
rollupOptions: {
input: {
main: "index.html",
config: "src/pages/config/index.html",
},
},
},
+8 -3
View File
@@ -1,22 +1,27 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {http} from '../models';
import {context} from '../models';
export function GetLibraryDirectory():Promise<string>;
export function GetScanConcurrency():Promise<string>;
export function GetThemeAccentColor():Promise<string>;
export function GetThemeBackgroundShade():Promise<string>;
export function Load():Promise<void>;
export function Save():Promise<void>;
export function ServeHTTP(arg1:http.ResponseWriter,arg2:http.Request):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetLibraryDirectory(arg1:string):Promise<void>;
export function SetScanConcurrency(arg1:string):Promise<void>;
export function SetThemeAccentColor(arg1:string):Promise<void>;
export function SetThemeBackgroundShade(arg1:string):Promise<void>;
export function Validate():Promise<void>;
+16 -4
View File
@@ -10,6 +10,14 @@ export function GetScanConcurrency() {
return window['go']['config']['Config']['GetScanConcurrency']();
}
export function GetThemeAccentColor() {
return window['go']['config']['Config']['GetThemeAccentColor']();
}
export function GetThemeBackgroundShade() {
return window['go']['config']['Config']['GetThemeBackgroundShade']();
}
export function Load() {
return window['go']['config']['Config']['Load']();
}
@@ -18,10 +26,6 @@ export function Save() {
return window['go']['config']['Config']['Save']();
}
export function ServeHTTP(arg1, arg2) {
return window['go']['config']['Config']['ServeHTTP'](arg1, arg2);
}
export function SetContext(arg1) {
return window['go']['config']['Config']['SetContext'](arg1);
}
@@ -34,6 +38,14 @@ export function SetScanConcurrency(arg1) {
return window['go']['config']['Config']['SetScanConcurrency'](arg1);
}
export function SetThemeAccentColor(arg1) {
return window['go']['config']['Config']['SetThemeAccentColor'](arg1);
}
export function SetThemeBackgroundShade(arg1) {
return window['go']['config']['Config']['SetThemeBackgroundShade'](arg1);
}
export function Validate() {
return window['go']['config']['Config']['Validate']();
}
-601
View File
@@ -1,132 +1,3 @@
export namespace http {
export class Response {
Status: string;
StatusCode: number;
Proto: string;
ProtoMajor: number;
ProtoMinor: number;
Header: Record<string, Array<string>>;
Body: any;
ContentLength: number;
TransferEncoding: string[];
Close: boolean;
Uncompressed: boolean;
Trailer: Record<string, Array<string>>;
Request?: Request;
TLS?: tls.ConnectionState;
static createFrom(source: any = {}) {
return new Response(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Status = source["Status"];
this.StatusCode = source["StatusCode"];
this.Proto = source["Proto"];
this.ProtoMajor = source["ProtoMajor"];
this.ProtoMinor = source["ProtoMinor"];
this.Header = source["Header"];
this.Body = source["Body"];
this.ContentLength = source["ContentLength"];
this.TransferEncoding = source["TransferEncoding"];
this.Close = source["Close"];
this.Uncompressed = source["Uncompressed"];
this.Trailer = source["Trailer"];
this.Request = this.convertValues(source["Request"], Request);
this.TLS = this.convertValues(source["TLS"], tls.ConnectionState);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Request {
Method: string;
URL?: url.URL;
Proto: string;
ProtoMajor: number;
ProtoMinor: number;
Header: Record<string, Array<string>>;
Body: any;
ContentLength: number;
TransferEncoding: string[];
Close: boolean;
Host: string;
Form: Record<string, Array<string>>;
PostForm: Record<string, Array<string>>;
MultipartForm?: multipart.Form;
Trailer: Record<string, Array<string>>;
RemoteAddr: string;
RequestURI: string;
TLS?: tls.ConnectionState;
Response?: Response;
Pattern: string;
static createFrom(source: any = {}) {
return new Request(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Method = source["Method"];
this.URL = this.convertValues(source["URL"], url.URL);
this.Proto = source["Proto"];
this.ProtoMajor = source["ProtoMajor"];
this.ProtoMinor = source["ProtoMinor"];
this.Header = source["Header"];
this.Body = source["Body"];
this.ContentLength = source["ContentLength"];
this.TransferEncoding = source["TransferEncoding"];
this.Close = source["Close"];
this.Host = source["Host"];
this.Form = source["Form"];
this.PostForm = source["PostForm"];
this.MultipartForm = this.convertValues(source["MultipartForm"], multipart.Form);
this.Trailer = source["Trailer"];
this.RemoteAddr = source["RemoteAddr"];
this.RequestURI = source["RequestURI"];
this.TLS = this.convertValues(source["TLS"], tls.ConnectionState);
this.Response = this.convertValues(source["Response"], Response);
this.Pattern = source["Pattern"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace library {
export class Album {
@@ -240,163 +111,6 @@ export namespace library {
}
export namespace multipart {
export class FileHeader {
Filename: string;
Header: Record<string, Array<string>>;
Size: number;
static createFrom(source: any = {}) {
return new FileHeader(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Filename = source["Filename"];
this.Header = source["Header"];
this.Size = source["Size"];
}
}
export class Form {
Value: Record<string, Array<string>>;
File: Record<string, Array<FileHeader>>;
static createFrom(source: any = {}) {
return new Form(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Value = source["Value"];
this.File = this.convertValues(source["File"], Array<FileHeader>, true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace net {
export class IPNet {
IP: number[];
Mask: number[];
static createFrom(source: any = {}) {
return new IPNet(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.IP = source["IP"];
this.Mask = source["Mask"];
}
}
}
export namespace pkix {
export class AttributeTypeAndValue {
Type: number[];
Value: any;
static createFrom(source: any = {}) {
return new AttributeTypeAndValue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Type = source["Type"];
this.Value = source["Value"];
}
}
export class Extension {
Id: number[];
Critical: boolean;
Value: number[];
static createFrom(source: any = {}) {
return new Extension(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Id = source["Id"];
this.Critical = source["Critical"];
this.Value = source["Value"];
}
}
export class Name {
Country: string[];
Organization: string[];
OrganizationalUnit: string[];
Locality: string[];
Province: string[];
StreetAddress: string[];
PostalCode: string[];
SerialNumber: string;
CommonName: string;
Names: AttributeTypeAndValue[];
ExtraNames: AttributeTypeAndValue[];
static createFrom(source: any = {}) {
return new Name(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Country = source["Country"];
this.Organization = source["Organization"];
this.OrganizationalUnit = source["OrganizationalUnit"];
this.Locality = source["Locality"];
this.Province = source["Province"];
this.StreetAddress = source["StreetAddress"];
this.PostalCode = source["PostalCode"];
this.SerialNumber = source["SerialNumber"];
this.CommonName = source["CommonName"];
this.Names = this.convertValues(source["Names"], AttributeTypeAndValue);
this.ExtraNames = this.convertValues(source["ExtraNames"], AttributeTypeAndValue);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace playlist {
export class Summary {
@@ -482,318 +196,3 @@ export namespace playlist {
}
export namespace tls {
export class ConnectionState {
Version: number;
HandshakeComplete: boolean;
DidResume: boolean;
CipherSuite: number;
CurveID: number;
NegotiatedProtocol: string;
NegotiatedProtocolIsMutual: boolean;
ServerName: string;
PeerCertificates: x509.Certificate[];
VerifiedChains: x509.Certificate[][];
SignedCertificateTimestamps: number[][];
OCSPResponse: number[];
TLSUnique: number[];
ECHAccepted: boolean;
static createFrom(source: any = {}) {
return new ConnectionState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Version = source["Version"];
this.HandshakeComplete = source["HandshakeComplete"];
this.DidResume = source["DidResume"];
this.CipherSuite = source["CipherSuite"];
this.CurveID = source["CurveID"];
this.NegotiatedProtocol = source["NegotiatedProtocol"];
this.NegotiatedProtocolIsMutual = source["NegotiatedProtocolIsMutual"];
this.ServerName = source["ServerName"];
this.PeerCertificates = this.convertValues(source["PeerCertificates"], x509.Certificate);
this.VerifiedChains = this.convertValues(source["VerifiedChains"], x509.Certificate);
this.SignedCertificateTimestamps = source["SignedCertificateTimestamps"];
this.OCSPResponse = source["OCSPResponse"];
this.TLSUnique = source["TLSUnique"];
this.ECHAccepted = source["ECHAccepted"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace url {
export class Userinfo {
static createFrom(source: any = {}) {
return new Userinfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class URL {
Scheme: string;
Opaque: string;
// Go type: Userinfo
User?: any;
Host: string;
Path: string;
RawPath: string;
OmitHost: boolean;
ForceQuery: boolean;
RawQuery: string;
Fragment: string;
RawFragment: string;
static createFrom(source: any = {}) {
return new URL(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Scheme = source["Scheme"];
this.Opaque = source["Opaque"];
this.User = this.convertValues(source["User"], null);
this.Host = source["Host"];
this.Path = source["Path"];
this.RawPath = source["RawPath"];
this.OmitHost = source["OmitHost"];
this.ForceQuery = source["ForceQuery"];
this.RawQuery = source["RawQuery"];
this.Fragment = source["Fragment"];
this.RawFragment = source["RawFragment"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace x509 {
export class PolicyMapping {
// Go type: OID
IssuerDomainPolicy: any;
// Go type: OID
SubjectDomainPolicy: any;
static createFrom(source: any = {}) {
return new PolicyMapping(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.IssuerDomainPolicy = this.convertValues(source["IssuerDomainPolicy"], null);
this.SubjectDomainPolicy = this.convertValues(source["SubjectDomainPolicy"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class OID {
static createFrom(source: any = {}) {
return new OID(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class Certificate {
Raw: number[];
RawTBSCertificate: number[];
RawSubjectPublicKeyInfo: number[];
RawSubject: number[];
RawIssuer: number[];
Signature: number[];
SignatureAlgorithm: number;
PublicKeyAlgorithm: number;
PublicKey: any;
Version: number;
// Go type: big
SerialNumber?: any;
Issuer: pkix.Name;
Subject: pkix.Name;
// Go type: time
NotBefore: any;
// Go type: time
NotAfter: any;
KeyUsage: number;
Extensions: pkix.Extension[];
ExtraExtensions: pkix.Extension[];
UnhandledCriticalExtensions: number[][];
ExtKeyUsage: number[];
UnknownExtKeyUsage: number[][];
BasicConstraintsValid: boolean;
IsCA: boolean;
MaxPathLen: number;
MaxPathLenZero: boolean;
SubjectKeyId: number[];
AuthorityKeyId: number[];
OCSPServer: string[];
IssuingCertificateURL: string[];
DNSNames: string[];
EmailAddresses: string[];
IPAddresses: number[][];
URIs: url.URL[];
PermittedDNSDomainsCritical: boolean;
PermittedDNSDomains: string[];
ExcludedDNSDomains: string[];
PermittedIPRanges: net.IPNet[];
ExcludedIPRanges: net.IPNet[];
PermittedEmailAddresses: string[];
ExcludedEmailAddresses: string[];
PermittedURIDomains: string[];
ExcludedURIDomains: string[];
CRLDistributionPoints: string[];
PolicyIdentifiers: number[][];
Policies: OID[];
InhibitAnyPolicy: number;
InhibitAnyPolicyZero: boolean;
InhibitPolicyMapping: number;
InhibitPolicyMappingZero: boolean;
RequireExplicitPolicy: number;
RequireExplicitPolicyZero: boolean;
PolicyMappings: PolicyMapping[];
static createFrom(source: any = {}) {
return new Certificate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Raw = source["Raw"];
this.RawTBSCertificate = source["RawTBSCertificate"];
this.RawSubjectPublicKeyInfo = source["RawSubjectPublicKeyInfo"];
this.RawSubject = source["RawSubject"];
this.RawIssuer = source["RawIssuer"];
this.Signature = source["Signature"];
this.SignatureAlgorithm = source["SignatureAlgorithm"];
this.PublicKeyAlgorithm = source["PublicKeyAlgorithm"];
this.PublicKey = source["PublicKey"];
this.Version = source["Version"];
this.SerialNumber = this.convertValues(source["SerialNumber"], null);
this.Issuer = this.convertValues(source["Issuer"], pkix.Name);
this.Subject = this.convertValues(source["Subject"], pkix.Name);
this.NotBefore = this.convertValues(source["NotBefore"], null);
this.NotAfter = this.convertValues(source["NotAfter"], null);
this.KeyUsage = source["KeyUsage"];
this.Extensions = this.convertValues(source["Extensions"], pkix.Extension);
this.ExtraExtensions = this.convertValues(source["ExtraExtensions"], pkix.Extension);
this.UnhandledCriticalExtensions = source["UnhandledCriticalExtensions"];
this.ExtKeyUsage = source["ExtKeyUsage"];
this.UnknownExtKeyUsage = source["UnknownExtKeyUsage"];
this.BasicConstraintsValid = source["BasicConstraintsValid"];
this.IsCA = source["IsCA"];
this.MaxPathLen = source["MaxPathLen"];
this.MaxPathLenZero = source["MaxPathLenZero"];
this.SubjectKeyId = source["SubjectKeyId"];
this.AuthorityKeyId = source["AuthorityKeyId"];
this.OCSPServer = source["OCSPServer"];
this.IssuingCertificateURL = source["IssuingCertificateURL"];
this.DNSNames = source["DNSNames"];
this.EmailAddresses = source["EmailAddresses"];
this.IPAddresses = source["IPAddresses"];
this.URIs = this.convertValues(source["URIs"], url.URL);
this.PermittedDNSDomainsCritical = source["PermittedDNSDomainsCritical"];
this.PermittedDNSDomains = source["PermittedDNSDomains"];
this.ExcludedDNSDomains = source["ExcludedDNSDomains"];
this.PermittedIPRanges = this.convertValues(source["PermittedIPRanges"], net.IPNet);
this.ExcludedIPRanges = this.convertValues(source["ExcludedIPRanges"], net.IPNet);
this.PermittedEmailAddresses = source["PermittedEmailAddresses"];
this.ExcludedEmailAddresses = source["ExcludedEmailAddresses"];
this.PermittedURIDomains = source["PermittedURIDomains"];
this.ExcludedURIDomains = source["ExcludedURIDomains"];
this.CRLDistributionPoints = source["CRLDistributionPoints"];
this.PolicyIdentifiers = source["PolicyIdentifiers"];
this.Policies = this.convertValues(source["Policies"], OID);
this.InhibitAnyPolicy = source["InhibitAnyPolicy"];
this.InhibitAnyPolicyZero = source["InhibitAnyPolicyZero"];
this.InhibitPolicyMapping = source["InhibitPolicyMapping"];
this.InhibitPolicyMappingZero = source["InhibitPolicyMappingZero"];
this.RequireExplicitPolicy = source["RequireExplicitPolicy"];
this.RequireExplicitPolicyZero = source["RequireExplicitPolicyZero"];
this.PolicyMappings = this.convertValues(source["PolicyMappings"], PolicyMapping);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
-1
View File
@@ -8,7 +8,6 @@ require (
github.com/a-h/templ v0.3.977
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/golang-cz/devslog v0.0.15
github.com/gorilla/schema v1.4.1
github.com/wailsapp/wails/v2 v2.10.2
golang.org/x/image v0.12.0
golang.org/x/sync v0.19.0
-2
View File
@@ -461,8 +461,6 @@ github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk
github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
-3
View File
@@ -1,5 +1,2 @@
{
"dependencies": {
"htmx": "^0.0.2"
}
}