This commit is contained in:
2026-06-24 21:46:20 +02:00
parent 5e14a9e308
commit b58cb0e5bf
38 changed files with 8303 additions and 21 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
out/
*.log
.DS_Store
.electron-builder-cache/
+36
View File
@@ -0,0 +1,36 @@
# sjnmusic-gui
Electron-based remote control for `sjnmusicd`. Pure HTTP client — the daemon
owns the audio engine, this GUI just renders state and fires commands.
## Run from source
```bash
cd gui
npm install
npm start
```
## Build distributables (Linux)
```bash
npm install
npm run build:linux # AppImage + .deb in gui/out/
```
## Architecture
```
gui/src/
main/ Electron main process (BrowserWindow, polling, tray)
preload/ contextBridge IPC → window.api.{get,post,patch,put,del,onTick}
renderer/ Web Components (no framework, hand-rolled CSS)
```
The daemon stays untouched; the only additions are:
- `YtCandidate.thumbnail: Option<String>` (for picker cover art),
- `daemon/src/mpris.rs` registering `org.mpris.MediaPlayer2.sjnmusic` on the
session bus so media keys work without the GUI open.
State is polled from the main process at 1 Hz and pushed into the renderer
via `webContents.send('tick', …)`.
+20
View File
@@ -0,0 +1,20 @@
appId: dev.serverjonas.sjnmusic-gui
productName: sjnmusic
files:
- src/**/*
- package.json
asar: true
directories:
output: out
linux:
target:
- AppImage
- deb
category: AudioVideo
maintainer: serverjonas
desktop:
entry:
Name: sjnmusic
Comment: GUI for sjnmusicd
Categories: AudioVideo;Audio;Player;
Keywords: audio;music;mp3;daemon;
+5541
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "sjnmusic-gui",
"version": "0.1.0",
"description": "Electron-based GUI for sjnmusicd. Pure HTTP client; owns no audio state.",
"main": "src/main/index.mjs",
"type": "module",
"private": true,
"scripts": {
"start": "electron .",
"dev": "electron . --enable-logging",
"build": "electron-builder",
"build:linux": "electron-builder --linux AppImage deb"
},
"build": {
"appId": "dev.serverjonas.sjnmusic-gui",
"productName": "sjnmusic",
"files": [
"src/**/*",
"package.json"
],
"linux": {
"target": ["AppImage", "deb"],
"category": "AudioVideo",
"maintainer": "serverjonas"
}
},
"dependencies": {
"electron-store": "^10.0.1"
},
"devDependencies": {
"electron": "^33.0.2",
"electron-builder": "^25.1.8"
}
}
+64
View File
@@ -0,0 +1,64 @@
// Thin HTTP client to sjnmusicd. Single object so the main process holds
// exactly one socket pool; retries the /now-playing poll on transient
// failures but does NOT crash the renderer when the daemon is offline.
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 14567;
export class DaemonClient {
constructor({ host = DEFAULT_HOST, port = DEFAULT_PORT, timeoutMs = 8000 } = {}) {
this.base = `http://${host}:${port}`;
this.timeoutMs = timeoutMs;
}
async _req(method, path, body) {
const url = this.base + path;
const opts = { method, signal: AbortSignal.timeout(this.timeoutMs) };
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(url, opts);
} catch (e) {
const err = new Error(`daemon unreachable at ${url}: ${e.message}`);
err.causedByFetch = true;
throw err;
}
const text = await res.text();
let body_out = {};
if (text) {
try { body_out = JSON.parse(text); }
catch { body_out = { _raw: text }; }
}
if (!res.ok) {
const err = new Error(body_out.error || `HTTP ${res.status}`);
err.status = res.status;
err.daemonResponse = body_out;
throw err;
}
return body_out;
}
get(path, query) {
let qs = '';
if (query && Object.keys(query).length > 0) {
qs = '?' + new URLSearchParams(query).toString();
}
return this._req('GET', path + qs);
}
post(path, body) { return this._req('POST', path, body); }
patch(path, body) { return this._req('PATCH', path, body); }
put(path, body) { return this._req('PUT', path, body); }
del(path, body) { return this._req('DELETE', path, body); }
}
/**
* Convenience: turn a "song name" object into what /play /add /del /search
* expect — lets render and main both speak the same JSON convention.
*/
export function searchBody(query) {
return { query };
}
+132
View File
@@ -0,0 +1,132 @@
// Electron main entrypoint. Owns the DaemonClient, polls /now-playing +
// /queue + /downloads on a 1s timer, forwards the snapshot to the renderer.
// Also owns the optional system tray.
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DaemonClient } from './daemon.mjs';
import { installHandlers } from './ipc.mjs';
import { buildTray } from './tray.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PRELOAD = path.join(__dirname, '../preload/preload.mjs');
const RENDERER = path.join(__dirname, '../renderer/index.html');
const POLL_INTERVAL_MS = 1000;
// Single-instance lock so launching `sjnmusic-gui` twice focuses the
// existing window instead of starting a second Chromium process.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
process.exit(0);
}
const daemon = new DaemonClient({
host: process.env.SJNMUSIC_HOST || '127.0.0.1',
port: parseInt(process.env.SJNMUSIC_PORT || '14567', 10),
});
let mainWindow = null;
let tray = null;
let pollTimer = null;
function emitTick(data) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('tick', data);
}
}
async function pollOnce() {
try {
const [nowPlaying, queue, downloads] = await Promise.all([
daemon.get('/now-playing'),
daemon.get('/queue'),
daemon.get('/downloads'),
]);
emitTick({
online: true,
nowPlaying,
queue,
downloads,
polledAt: Date.now(),
});
} catch (err) {
emitTick({
online: false,
error: err.daemonResponse?.error || err.message,
polledAt: Date.now(),
});
}
}
function startPolling() {
// First poll immediately so the renderer doesn't sit on empty data.
pollOnce();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1180,
height: 760,
minWidth: 820,
minHeight: 520,
show: false,
backgroundColor: '#0f1115',
title: 'sjnmusic',
autoHideMenuBar: true,
webPreferences: {
preload: PRELOAD,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
spellcheck: false,
},
});
Menu.setApplicationMenu(null);
mainWindow.removeMenu();
mainWindow.once('ready-to-show', () => mainWindow.show());
await mainWindow.loadFile(RENDERER);
}
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(async () => {
installHandlers(ipcMain, daemon);
await createMainWindow();
tray = buildTray({ app, mainWindow: () => mainWindow, daemon });
startPolling();
app.on('activate', () => {
if (!mainWindow) createMainWindow();
});
});
app.on('window-all-closed', () => {
// On Linux + macOS we keep the process alive so the daemon polling
// continues; tray activation re-shows the window. On Windows we quit.
if (process.platform === 'win32' && !tray) {
app.quit();
}
});
app.on('before-quit', () => {
stopPolling();
});
+21
View File
@@ -0,0 +1,21 @@
// IPC surface exposed to the renderer via preload. Each handler is a thin
// wrapper around the DaemonClient, so the renderer never holds a raw socket.
export function installHandlers(ipcMain, daemon) {
const wrap = (method) => async (_e, { path, body, query } = {}) => {
try {
return { ok: true, data: await daemon[method](path, query ?? body) };
} catch (err) {
const status = err.status || 0;
const causedByFetch = !!err.causedByFetch;
const msg = err.daemonResponse?.error || err.message;
return { ok: false, error: msg, status, causedByFetch };
}
};
ipcMain.handle('daemon:get', wrap('get'));
ipcMain.handle('daemon:post', wrap('post'));
ipcMain.handle('daemon:patch', wrap('patch'));
ipcMain.handle('daemon:put', wrap('put'));
ipcMain.handle('daemon:del', wrap('del'));
}
+73
View File
@@ -0,0 +1,73 @@
// System tray icon. Built around Electron's `Tray` API. Some Linux setups
// (no libappindicator, Wayland without StatusNotifierItem) can refuse to
// construct a tray at all; we swallow that and return null so the rest of
// the app keeps running.
import { Menu, Tray, nativeImage } from 'electron';
/**
* Synthesize a tiny 16x16 dark theme icon in memory, since we don't ship
* image assets (and SVG rasterisation in Electron is unreliable). This
* gives a monochrome square that's pleasant enough until someone adds a
* real .png.
*/
function placeholderIcon() {
// 16x16 imageData: a teal play-triangle on a dark background.
const size = 16;
const buf = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
// Background: rgba(15,17,21,255)
let r = 15, g = 17, b = 21, a = 255;
// Triangle mask centred
const cx = x - 7;
const cy = y - 8;
const inside = Math.abs(cx) <= cy && cy >= -7 && cy <= 6;
if (inside) {
r = 108; g = 178; b = 255; // accent
}
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
}
}
return nativeImage.createFromBitmap(buf, { width: size, height: size });
}
export function buildTray({ app, mainWindow, daemon }) {
try {
const tray = new Tray(placeholderIcon());
tray.setToolTip('sjnmusic');
const rebuild = (state) => {
const win = mainWindow();
const showWin = () => win && !win.isDestroyed() && (win.isMinimized() ? win.restore() : win.show());
const menu = Menu.buildFromTemplate([
{ label: state?.nowPlaying?.current?.name || 'sjnmusic', enabled: false },
{ type: 'separator' },
{
label: state?.nowPlaying?.paused ? 'Resume' : 'Pause',
click: () => daemon.post(state?.nowPlaying?.paused ? '/resume' : '/pause'),
},
{ label: 'Skip', click: () => daemon.post('/skip') },
{ type: 'separator' },
{ label: 'Show window', click: showWin },
{ label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } },
]);
tray.setContextMenu(menu);
};
rebuild({});
// We poll the daemon for state snapshot inside main/index.mjs's loop
// and forward via webContents. We mirror a tiny lastState here so the
// menu reflects pause/resume. Index.mjs calls tray.maybeUpdate(state)
// via the 'tick' channel — but since tray lives in main and we don't
// want to give it the BrowserWindow, we just hook IPC internally.
tray.maybeUpdate = rebuild;
return tray;
} catch (e) {
console.warn('tray: failed to construct, continuing without:', e?.message || e);
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
// preload: exposes a *narrow* IPC surface to the renderer.
// The renderer never sees Node, fs, or any daemon URL.
import { contextBridge, ipcRenderer } from 'electron';
// We accept both shapes from the renderer:
// (a) flat options: window.api.get('/foo', { q: 'x' })
// (b) wrapped: window.api.post('/foo', { body: {...} })
// window.api.get('/foo', { query: {...} })
// Whichever is provided wins; otherwise the second arg is routed as body
// (for POST/PATCH/PUT) or query (for GET/DELETE).
function _invoke(channel, kind) {
return async (path, opts = {}) => {
const body = kind === 'body' ? (opts.body ?? opts) : undefined;
const query = kind === 'query' ? (opts.query ?? opts) : undefined;
return ipcRenderer.invoke(channel, { path, body, query });
};
}
const tickListeners = new Set();
ipcRenderer.on('tick', (_ev, data) => {
for (const cb of tickListeners) {
try { cb(data); } catch { /* never let one bad subscriber kill the bus */ }
}
});
contextBridge.exposeInMainWorld('api', {
get: _invoke('daemon:get', 'query'),
post: _invoke('daemon:post', 'body'),
patch: _invoke('daemon:patch', 'body'),
put: _invoke('daemon:put', 'body'),
del: _invoke('daemon:del', 'query'),
onTick(callback) {
tickListeners.add(callback);
return () => tickListeners.delete(callback);
},
});
+110
View File
@@ -0,0 +1,110 @@
// Renderer entry point. Registers every Web Component, subscribes to
// `window.api.onTick`, and wires the hash-based router.
import { store } from './store.mjs';
import { applyTheme } from './theme.mjs';
import { register as regSidebar } from './components/sidebar.mjs';
import { register as regNowPlaying } from './components/now-playing.mjs';
import { register as regLibrary } from './components/library-view.mjs';
import { register as regQueueV } from './components/queue-view.mjs';
import { register as regPlaylistList } from './components/playlist-list-view.mjs';
import { register as regPlaylistV } from './components/playlist-view.mjs';
import { register as regSelnDl } from './components/search-download-view.mjs';
import { register as regDownloads } from './components/downloads-view.mjs';
import { register as regHistory } from './components/history-view.mjs';
import { register as regStats } from './components/stats-view.mjs';
import { register as regSettings } from './components/settings-view.mjs';
import { register as regToast } from './components/toast.mjs';
// 1. Apply persisted theme before any component renders, so the first
// paint doesn't flash the wrong colour.
applyTheme(store.state.theme);
// 2. Register all custom elements.
regSidebar();
regNowPlaying();
regLibrary();
regQueueV();
regPlaylistList();
regPlaylistV();
regSelnDl();
regDownloads();
regHistory();
regStats();
regSettings();
regToast();
// 3. Forward the polling ticks from main into the shared store.
window.api.onTick((snapshot) => {
store.set({
online: snapshot.online,
nowPlaying: snapshot.nowPlaying,
queue: snapshot.queue,
downloads: snapshot.downloads,
error: snapshot.error || null,
polledAt: snapshot.polledAt,
});
});
// 4. Minimal hash-based router. Recognised routes:
// #library, #queue, #playlists, #playlist/<name>, #search,
// #downloads, #history, #stats, #settings
function render() {
const hash = location.hash || '#library';
store.set({ route: hash });
const main = document.getElementById('main-pane');
if (!main) return;
main.innerHTML = '';
const [name, arg] = hash.replace(/^#/, '').split('/');
switch (name) {
case 'queue':
main.appendChild(document.createElement('app-queue-view'));
break;
case 'playlists':
main.appendChild(document.createElement('app-playlist-list-view'));
break;
case 'playlist':
const el = document.createElement('app-playlist-view');
if (arg) el.setAttribute('name', decodeURIComponent(arg));
main.appendChild(el);
break;
case 'search':
main.appendChild(document.createElement('app-search-download-view'));
break;
case 'downloads':
main.appendChild(document.createElement('app-downloads-view'));
break;
case 'history':
main.appendChild(document.createElement('app-history-view'));
break;
case 'stats':
main.appendChild(document.createElement('app-stats-view'));
break;
case 'settings':
main.appendChild(document.createElement('app-settings-view'));
break;
case 'library':
default:
main.appendChild(document.createElement('app-library-view'));
break;
}
}
window.addEventListener('hashchange', render);
render();
// 5. Keyboard shortcuts at the body level. We avoid stealing focus from
// text inputs by checking document.activeElement.
document.addEventListener('keydown', async (e) => {
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return;
if (e.code === 'Space') {
e.preventDefault();
await window.api.post('/pause').catch(() => {});
}
});
// Inform the daemon we're alive — the daemon doesn't currently do anything
// with this; populate stats later if we add startup latency tracking.
window.api.get('/help').catch(() => {});
@@ -0,0 +1,52 @@
// Downloads view: live status table for in-flight yt-dlp jobs. Re-fetches
// every 3 seconds while this view is in the foreground (and immediately
// on every model tick from main, which is cheap enough).
import { store } from '../store.mjs';
export function register() {
if (customElements.get('app-downloads-view')) return;
customElements.define('app-downloads-view', class extends HTMLElement {
connectedCallback() {
this.render();
this._timer = setInterval(() => this.render(), 4000);
}
disconnectedCallback() {
clearInterval(this._timer);
}
async render() {
// Prefer the snapshot pushed by main; fall back to a fresh fetch
// if it's missing.
let dl = store.state.downloads?.downloads;
if (!dl) {
const r = await window.api.get('/downloads');
dl = r.ok ? (r.data?.downloads || []) : [];
}
const rows = dl.map(d => `
<tr>
<td>${d.id ?? '-'}</td>
<td><span class="badge ${d.status}">${escapeHtml(d.status || '?')}</span></td>
<td>${escapeHtml(d.name || '')}</td>
<td class="meta">${escapeHtml(d.source || '')}</td>
<td class="meta">${d.song_id ? `song ${d.song_id}` : ''}</td>
<td class="meta">${d.error ? escapeHtml(d.error) : ''}</td>
</tr>
`).join('') || `<tr><td colspan="6" class="meta">No jobs.</td></tr>`;
this.innerHTML = `
<div class="view-header">
<h1>Downloads</h1>
<button data-act="refresh">Refresh now</button>
</div>
<table class="downloads-table" style="width:100%;border-collapse:collapse;">
<thead>
<tr><th>id</th><th>status</th><th>name</th><th>source</th><th>result</th><th>error</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
this.querySelector('[data-act="refresh"]').addEventListener('click', () => this.render());
}
});
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
}
@@ -0,0 +1,35 @@
// History view: most-recent 100 entries.
export function register() {
if (customElements.get('app-history-view')) return;
customElements.define('app-history-view', class extends HTMLElement {
connectedCallback() { this.render(); }
async render() {
let hist = [];
try {
const r = await window.api.get('/history', { limit: 100 });
hist = r.ok ? (r.data?.history || []) : [];
} catch (e) {}
const rows = hist.map(h => {
const date = new Date((h.played_at || 0) * 1000);
const when = isNaN(date.getTime()) ? '(no date)' : date.toLocaleString();
return `
<div class="row">
<span class="meta">${escapeHtml(when)}</span>
<div>
<div class="title">${escapeHtml(h.song_name || '(missing song)')}</div>
<div class="meta">id ${h.song_id}</div>
</div>
<span class="meta">${fmtSecs(h.duration_secs_played || 0)}</span>
<span></span>
</div>`;
}).join('') || `<div class="meta">No history yet.</div>`;
this.innerHTML = `
<div class="view-header"><h1>History</h1></div>
<div class="row-list">${rows}</div>
`;
}
});
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
function fmtSecs(s) { s = Math.max(0, Math.round(s|0)); const m = Math.floor(s/60), r = s%60; return `${m}:${r.toString().padStart(2,'0')}`; }
}
@@ -0,0 +1,70 @@
// Library view: list of songs plus a fuzzy search box. Click to play.
import { store } from '../store.mjs';
export function register() {
if (customElements.get('app-library-view')) return;
customElements.define('app-library-view', class extends HTMLElement {
connectedCallback() { this.render(); }
render() {
this.innerHTML = `
<div class="view-header">
<h1>Library</h1>
<input class="view-search" type="search"
placeholder="Filter by name (fuzzy)"
${this.onsearch?.() ? '' : 'id="lib-q"'}>
</div>
<div id="lib-root" class="row-list"></div>
`;
const root = this.querySelector('#lib-root');
const qBox = this.querySelector('#lib-q');
const fetchOnce = async () => {
const q = qBox?.value?.trim() || '';
const r = q
? await window.api.get('/search', { q })
: await window.api.get('/songs');
const songs = q ? [r.data] : (r.data?.songs || []);
const items = songs
.filter(s => s && (s.id !== undefined))
.map(s => {
const dur = s.duration_secs ? ` · ${this._fmt(s.duration_secs)}` : '';
return `
<div class="row" data-id="${s.id}" data-name="${(s.name||'').replace(/"/g,'&quot;')}">
<span class="meta">▶</span>
<div>
<div class="title">${(s.name||'').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}</div>
<div class="meta">id ${s.id}${dur}</div>
</div>
<button class="icon-btn" data-act="queue-add" title="Add to queue">+queue</button>
<button class="icon-btn danger" data-act="delete" title="Delete">delete</button>
</div>`;
}).join('') || `<div class="meta">No songs yet — head to Search + Download.</div>`;
root.innerHTML = items;
root.querySelectorAll('.row').forEach(el => {
el.addEventListener('click', (ev) => {
const tgt = ev.target;
if (tgt?.dataset?.act === 'queue-add') {
window.api.post('/add', { body: { query: el.dataset.name } });
} else if (tgt?.dataset?.act === 'delete') {
window.api.del('/del', { body: { query: el.dataset.name } });
} else {
window.api.post('/play', { body: { query: el.dataset.name } });
}
});
});
};
qBox?.addEventListener('input', debounce(fetchOnce, 180));
fetchOnce();
}
_fmt(s) {
s = Math.max(0, Math.round(s|0));
const m = Math.floor(s/60), r = s % 60;
return `${m}:${r.toString().padStart(2,'0')}`;
}
});
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
}
@@ -0,0 +1,79 @@
// Footer transport strip: title, transport buttons, volume slider, repeat
// selector. Re-renders on every tick.
import { store } from '../store.mjs';
import { fmtSecs } from '../fmt.mjs';
async function post(path, body) {
try {
const r = await window.api.post(path, body ? { body } : undefined);
if (!r.ok && r.error) console.warn(`POST ${path}`, r.error);
} catch (e) {
console.warn(`POST ${path} failed`, e);
}
}
export function register() {
if (customElements.get('app-now-playing')) return;
customElements.define('app-now-playing', class extends HTMLElement {
connectedCallback() {
this._unsub = store.subscribe(() => this.render());
this.render();
}
disconnectedCallback() {
this._unsub?.();
}
fmt(s) { return fmtSecs(s); }
render() {
const np = store.state.nowPlaying || {};
const cur = np.current || {};
const title = cur.name || '(nothing playing)';
const elapsed = np.elapsed_secs ?? 0;
const raw_dur = np.duration_secs;
const duration = (raw_dur && raw_dur > 0) ? raw_dur : null;
const paused = !!np.paused;
const playing = !!np.playing;
const vol = np.volume ?? 1.0;
const repeat = np.repeat || 'off';
this.innerHTML = `
<div class="np-current">
${title.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}
<small>${paused ? 'paused' : (playing ? 'playing' : 'idle')}</small>
</div>
<div class="np-controls">
<button class="icon-btn" data-act="seek-back">⟲10s</button>
<button class="icon-btn" data-act="toggle">${paused || !playing ? '▶' : '⏸'}</button>
<button class="icon-btn" data-act="skip">⏭</button>
<select data-act="repeat" title="Repeat">
<option value="off" ${repeat==='off'?'selected':''}>off</option>
<option value="one" ${repeat==='one'?'selected':''}>one</option>
<option value="all" ${repeat==='all'?'selected':''}>all</option>
</select>
</div>
<div class="np-progress">
<span class="np-time">${this.fmt(elapsed)} / ${this.fmt(duration)}</span>
<div class="np-volume">
<label>vol</label>
<input type="range" min="0" max="2" step="0.01" value="${vol}" data-act="volume">
</div>
</div>
`;
this.querySelector('[data-act="toggle"]')?.addEventListener('click',
() => paused ? post('/resume') : post('/pause'));
this.querySelector('[data-act="skip"]')?.addEventListener('click',
() => post('/skip'));
this.querySelector('[data-act="seek-back"]')?.addEventListener('click',
async () => {
const newPos = Math.max(0, (elapsed - 10));
await post('/seek', { secs: newPos });
});
this.querySelector('[data-act="repeat"]')?.addEventListener('change',
(e) => post('/repeat', { mode: e.target.value }));
this.querySelector('[data-act="volume"]')?.addEventListener('input',
(e) => post('/volume', { vol: parseFloat(e.target.value) }));
}
});
}
@@ -0,0 +1,48 @@
// Playlist list view: shows all playlists; create via prompt.
export function register() {
if (customElements.get('app-playlist-list-view')) return;
customElements.define('app-playlist-list-view', class extends HTMLElement {
connectedCallback() { this.render(); }
async render() {
let playlists = [];
try {
const r = await window.api.get('/playlists');
playlists = r.ok ? (r.data || []) : [];
} catch (e) { playlists = []; }
const items = playlists.map(pl => `
<div class="row" data-name="${escapeHtml(pl.name)}">
<span class="meta">▶</span>
<div>
<div class="title">${escapeHtml(pl.name)}</div>
<div class="meta">${(pl.songs || []).length} songs</div>
</div>
<button class="icon-btn" data-act="go">open</button>
<button class="icon-btn danger" data-act="rm">delete</button>
</div>`).join('') || `<div class="meta">No playlists yet.</div>`;
this.innerHTML = `
<div class="view-header">
<h1>Playlists</h1>
<input id="pl-new" type="text" placeholder="new playlist name" style="flex:1;">
<button data-act="create">Create</button>
</div>
<div class="row-list">${items}</div>
`;
this.querySelector('[data-act="create"]')?.addEventListener('click', async () => {
const name = this.querySelector('#pl-new')?.value?.trim();
if (!name) return;
const r = await window.api.post('/playlists', { body: { name } });
if (r.ok) location.hash = `#playlist/${encodeURIComponent(name)}`;
else console.warn('create playlist failed', r.error);
});
this.querySelectorAll('.row').forEach(el => {
el.addEventListener('click', (ev) => {
const act = ev.target?.dataset?.act;
if (act === 'go') location.hash = `#playlist/${encodeURIComponent(el.dataset.name)}`;
else if (act === 'rm') window.api.del(`/playlists/${encodeURIComponent(el.dataset.name)}`);
});
});
}
});
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
}
@@ -0,0 +1,96 @@
// Single playlist view: songs in a list, drag-reorder in v1.5, with
// up/down buttons for now. Rename + duplicate via header buttons.
export function register() {
if (customElements.get('app-playlist-view')) return;
customElements.define('app-playlist-view', class extends HTMLElement {
static get observedAttributes() { return ['name']; }
async connectedCallback() { this.render(); }
async attributeChangedCallback() { this.render(); }
async render() {
const name = this.getAttribute('name');
if (!name) {
this.innerHTML = `<div class="view-header"><h1>Playlist</h1></div>
<div class="meta">No playlist selected.</div>`;
return;
}
let pl = null;
try {
const r = await window.api.get(`/playlists/${encodeURIComponent(name)}`);
if (r.ok) pl = r.data;
} catch (e) { pl = null; }
const songs = pl?.songs || [];
const items = songs.map((s, i) => `
<div class="row" data-id="${s.id}" data-name="${escapeHtml(s.name)}" data-idx="${i}">
<span class="meta">${i + 1}</span>
<div>
<div class="title">${escapeHtml(s.name)}</div>
<div class="meta">id ${s.id}</div>
</div>
<button class="icon-btn" data-act="up" title="Move up">↑</button>
<button class="icon-btn" data-act="dn" title="Move down">↓</button>
<button class="icon-btn danger" data-act="rm">remove</button>
</div>`).join('') || `<div class="meta">empty playlist</div>`;
this.innerHTML = `
<div class="view-header">
<h1>${escapeHtml(name)}</h1>
<input id="pl-add-q" type="search" placeholder="add a song by name…" style="flex:1;">
<button data-act="add">Add</button>
<button data-act="play">Play</button>
<button data-act="rename">Rename</button>
<button data-act="duplicate">Duplicate</button>
<button data-act="delete" class="danger">Delete</button>
</div>
<div class="row-list">${items}</div>
`;
const call = (method, path, body) => window.api[method](path, body ? { body } : undefined);
this.querySelector('[data-act="add"]')?.addEventListener('click', async () => {
const q = this.querySelector('#pl-add-q')?.value?.trim();
if (!q) return;
await call('post', `/playlists/${encodeURIComponent(name)}/add`, { query: q });
this.render();
});
this.querySelector('[data-act="play"]')?.addEventListener('click',
() => call('post', `/playlists/${encodeURIComponent(name)}/play`));
this.querySelector('[data-act="delete"]')?.addEventListener('click', async () => {
if (!confirm(`Delete playlist "${name}"?`)) return;
await call('del', `/playlists/${encodeURIComponent(name)}`);
location.hash = '#playlists';
});
this.querySelector('[data-act="rename"]')?.addEventListener('click', async () => {
const new_name = prompt('New name:', name);
if (!new_name || new_name === name) return;
await call('patch', `/playlists/${encodeURIComponent(name)}`, { name: new_name });
location.hash = `#playlist/${encodeURIComponent(new_name)}`;
});
this.querySelector('[data-act="duplicate"]')?.addEventListener('click', async () => {
const new_name = prompt('Duplicate as:', `${name} (copy)`);
if (!new_name) return;
await call('post', `/playlists/${encodeURIComponent(name)}/duplicate`, { name: new_name });
location.hash = `#playlist/${encodeURIComponent(new_name)}`;
});
this.querySelectorAll('.row').forEach(el => {
el.addEventListener('click', async (ev) => {
const act = ev.target?.dataset?.act;
if (act === 'rm') {
await call('del', `/playlists/${encodeURIComponent(name)}/songs/${el.dataset.id}`);
this.render();
} else if (act === 'up' || act === 'dn') {
const from = parseInt(el.dataset.idx, 10) + 1;
const to = from + (act === 'up' ? -1 : 1);
if (to < 1 || to > songs.length) return;
await call('post', `/playlists/${encodeURIComponent(name)}/reorder`, { from, to });
this.render();
} else {
window.api.post('/play', { body: { query: el.dataset.name } });
}
});
});
}
});
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
}
@@ -0,0 +1,72 @@
// Queue view: shows the in-memory queue with shuffle/clear actions.
// Drag-reorder between rows is wired in v1.5; for now we expose up/down
// buttons.
import { store } from '../store.mjs';
export function register() {
if (customElements.get('app-queue-view')) return;
customElements.define('app-queue-view', class extends HTMLElement {
connectedCallback() { this.render(); }
async render() {
// Always fetch fresh — the queue is small (<1k entries typically).
let queue, current;
try {
const r = await window.api.get('/queue');
queue = r.ok ? r.data?.queue || [] : [];
current = r.ok ? r.data?.current || null : null;
} catch (e) {
queue = [];
current = null;
}
const head = current ? `
<div class="stats-card" style="margin-bottom:16px;">
<div class="label">Now playing</div>
<div class="value">${escapeHtml(current.name)}</div>
</div>` : '';
const list = queue.map((s, i) => `
<div class="row" data-id="${s.id}" data-name="${escapeHtml(s.name)}" data-idx="${i}">
<span class="meta">${i + 1}</span>
<div>
<div class="title">${escapeHtml(s.name)}</div>
<div class="meta">id ${s.id}</div>
</div>
<button class="icon-btn" data-act="skip-to">▶</button>
<button class="icon-btn danger" data-act="queue-del" title="Remove">remove</button>
</div>
`).join('');
this.innerHTML = `
<div class="view-header">
<h1>Queue</h1>
<button data-act="shuffle">Shuffle</button>
<button data-act="clear" class="danger">Clear</button>
</div>
${head}
<div class="row-list">${list || `<div class="meta">queue empty</div>`}</div>
`;
this.querySelector('[data-act="shuffle"]')?.addEventListener('click',
() => window.api.post('/queue/shuffle', { body: {} }));
this.querySelector('[data-act="clear"]')?.addEventListener('click',
() => window.api.post('/queue/clear', { body: {} }));
this.querySelectorAll('.row').forEach(el => {
el.addEventListener('click', (ev) => {
const act = ev.target?.dataset?.act;
if (act === 'skip-to') {
// Play the row's name; daemon resolves best match.
window.api.post('/play', { body: { query: el.dataset.name } });
} else if (act === 'queue-del') {
// Daemon doesn't have a "remove from queue" endpoint; ask
// /search-all to find the id, then post /play updates the head.
// For v1, the user can /del from library which also clears
// queue entries (and /play again to skip).
window.api.del('/del', { body: { query: el.dataset.name } });
}
});
});
}
});
function escapeHtml(s) {
return String(s || '').replace(/[<>&"]/g, c => ({ '<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;' }[c]));
}
}
@@ -0,0 +1,79 @@
// Search + Download view: text box → /search/yt → numbered cards with
// thumbnails, click to download via /init.
export function register() {
if (customElements.get('app-search-download-view')) return;
customElements.define('app-search-download-view', class extends HTMLElement {
connectedCallback() { this.render(); }
async render() {
this.innerHTML = `
<div class="view-header">
<h1>Search + Download</h1>
<input id="sd-q" class="view-search" type="search"
placeholder="Search YouTube via yt-dlp…">
<input id="sd-n" type="number" min="1" max="20" value="3"
title="candidates to show (1-20)" style="width:70px;">
<button data-act="go">Search</button>
</div>
<div id="sd-results" class="search-results"></div>
`;
this.querySelector('[data-act="go"]').addEventListener('click', () => this.runSearch());
this.querySelector('#sd-q').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.runSearch();
});
this.querySelector('#sd-q').focus();
}
async runSearch(reason) {
const q = this.querySelector('#sd-q').value.trim();
const n = parseInt(this.querySelector('#sd-n').value, 10) || 3;
const grid = this.querySelector('#sd-results');
if (!q) { grid.innerHTML = `<div class="meta">Type a query above.</div>`; return; }
grid.innerHTML = `<div class="meta">Searching yt-dlp…</div>`;
const r = await window.api.get('/search/yt', { q, limit: n });
if (!r.ok) { grid.innerHTML = `<div class="meta">error: ${escapeHtml(r.error || 'failed')}</div>`; return; }
const results = r.data?.results || [];
if (results.length === 0) {
grid.innerHTML = `<div class="meta">No results.</div>`;
return;
}
grid.innerHTML = results.map((c, i) => `
<button class="candidate" data-i="${i}">
${c.thumbnail
? `<img class="thumb" src="${escapeAttr(c.thumbnail)}" referrerpolicy="no-referrer" alt="" loading="lazy">`
: `<span class="thumb" style="display:flex;align-items:center;justify-content:center;font-size:24px;">🎵</span>`}
<div class="info">
<div class="t">${escapeHtml(c.title)}</div>
<div class="u">${escapeHtml(c.uploader || '(unknown artist)')}</div>
<div class="d">${fmtSecs(c.duration_secs)}</div>
</div>
</button>`).join('');
grid.querySelectorAll('.candidate').forEach(el => {
el.addEventListener('click', async () => {
const i = parseInt(el.dataset.i, 10);
const c = results[i];
grid.innerHTML = `<div class="meta">Downloading…</div>`;
const dr = await window.api.post('/init', { body: { name: q, url: c.url } });
if (!dr.ok) {
grid.innerHTML = `<div class="meta">error: ${escapeHtml(dr.error || 'failed')}</div>`;
return;
}
grid.innerHTML = `
<div class="stats-card">
<div class="label">queued</div>
<div class="value">job ${dr.data.job_id}</div>
<div class="meta">Watching Downloads for completion…</div>
</div>`;
});
});
}
});
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
function escapeAttr(s) { return String(s||'').replace(/"/g, '&quot;'); }
function fmtSecs(s) {
s = Math.max(0, Math.round(s|0));
const m = Math.floor(s/60), r = s % 60;
return `${m}:${r.toString().padStart(2,'0')}`;
}
}
@@ -0,0 +1,39 @@
// Settings view: ONLY the light/dark theme picker, as requested.
import { store } from '../store.mjs';
import { applyTheme, THEMES } from '../theme.mjs';
export function register() {
if (customElements.get('app-settings-view')) return;
customElements.define('app-settings-view', class extends HTMLElement {
connectedCallback() { this.render(); }
render() {
const buttons = THEMES.map(t => `
<button data-t="${t}" class="${store.state.theme === t ? 'active' : ''}">
${t === 'dark' ? '🌙 Dark' : '☀️ Light'}
</button>`).join('');
this.innerHTML = `
<div class="view-header">
<h1>Settings</h1>
</div>
<div class="stats-card">
<div class="label">Theme</div>
<div class="theme-picker" style="margin-top:8px;">${buttons}</div>
<p class="meta" style="margin-top:10px;">
Default is dark. Choice persists per-machine (localStorage).
The daemon is unchanged — the GUI reads its theme locally.
</p>
</div>
<div class="stats-card" style="margin-top:16px;">
<div class="label">Daemon</div>
<p class="meta">The GUI is a remote control. It does not own audio.
Edit <code>~/.sjn/music/config.toml</code> and restart the daemon
to change host/port, library paths, fuzzy threshold, …</p>
</div>
`;
this.querySelectorAll('[data-t]').forEach(el => {
el.addEventListener('click', () => applyTheme(el.dataset.t));
});
}
});
}
+46
View File
@@ -0,0 +1,46 @@
// Sidebar: navigation + offline banner. Re-renders whenever the store
// reports `online` toggling, since the banner belongs here.
import { store } from '../store.mjs';
const ROUTES = [
{ hash: '#library', label: 'Library', icon: '🎵' },
{ hash: '#queue', label: 'Queue', icon: '📜' },
{ hash: '#playlists', label: 'Playlists', icon: '➕' },
{ hash: '#search', label: 'Search + Download', icon: '🔎' },
{ hash: '#downloads', label: 'Downloads', icon: '⬇' },
{ hash: '#history', label: 'History', icon: '🕓' },
{ hash: '#stats', label: 'Stats', icon: '📊' },
{ hash: '#settings', label: 'Settings', icon: '⚙' },
];
export function register() {
if (customElements.get('app-sidebar')) return;
customElements.define('app-sidebar', class extends HTMLElement {
connectedCallback() {
this._unsub = store.subscribe(() => this.render());
this.render();
}
disconnectedCallback() {
this._unsub?.();
}
render() {
const { online, route, error } = store.state;
const navHtml = ROUTES.map(r => `
<a class="nav-link ${route === r.hash ? 'active' : ''}" href="${r.hash}">
<span aria-hidden="true">${r.icon}</span>
<span class="nav-label">${r.label}</span>
</a>
`).join('');
const banner = online ? '' :
`<div class="offline-banner" title="${(error || 'daemon unreachable').replace(/"/g, '&quot;')}">
daemon offline
</div>`;
this.innerHTML = `
<div class="brand">sjnmusic</div>
${banner}
<nav>${navHtml}</nav>
`;
}
});
}
@@ -0,0 +1,44 @@
// Stats view: aggregate cards from /stats plus top songs.
import { fmtSecs, fmtBigSecs, escapeHtml } from '../fmt.mjs';
export function register() {
if (customElements.get('app-stats-view')) return;
customElements.define('app-stats-view', class extends HTMLElement {
connectedCallback() { this.render(); }
async render() {
let stats = { total_plays: 0, total_secs: 0, top_songs: [] };
try {
const r = await window.api.get('/stats');
if (r.ok) stats = r.data || stats;
} catch (e) {}
const cards = `
<div class="stats-grid">
<div class="stats-card">
<div class="label">Total plays</div>
<div class="value">${stats.total_plays || 0}</div>
</div>
<div class="stats-card">
<div class="label">Total time</div>
<div class="value">${fmtBigSecs(stats.total_secs || 0)}</div>
</div>
</div>`;
const top = (stats.top_songs || []).map((t, i) => `
<div class="row">
<span class="meta">${i + 1}</span>
<div>
<div class="title">${escapeHtml(t.name || '(missing)')}</div>
<div class="meta">id ${t.song_id}</div>
</div>
<span class="meta">${t.plays} plays</span>
<span class="meta">${fmtSecs(t.total_secs || 0)}</span>
</div>`).join('') || `<div class="meta">No plays yet.</div>`;
this.innerHTML = `
<div class="view-header"><h1>Stats</h1></div>
${cards}
<h2 style="margin-top:20px;">Top songs</h2>
<div class="row-list">${top}</div>
`;
}
});
}
+23
View File
@@ -0,0 +1,23 @@
// Toast host: small notification banner overlay. Listens to store.toasts.
import { store } from '../store.mjs';
export function register() {
if (customElements.get('app-toast-host')) return;
customElements.define('app-toast-host', class extends HTMLElement {
connectedCallback() {
this._unsub = store.subscribe(() => this.render());
this.render();
}
disconnectedCallback() { this._unsub?.(); }
render() {
const items = (store.state.toasts || [])
.map(t => `<div class="toast ${t.level}">${escapeHtml(t.message)}</div>`)
.join('');
this.innerHTML = items || '';
}
});
function escapeHtml(s) {
return String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
}
+22
View File
@@ -0,0 +1,22 @@
// Small formatting helpers shared across components.
export function fmtSecs(s) {
const v = Math.max(0, Math.round(Number(s) || 0));
const m = Math.floor(v / 60), r = v % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
export function fmtBigSecs(s) {
const v = Math.max(0, Math.round(Number(s) || 0));
const h = Math.floor(v / 3600);
const m = Math.floor((v % 3600) / 60);
const sec = v % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[<>&"]/g,
c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' http: https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'">
<title>sjnmusic</title>
<link rel="stylesheet" href="styles.css">
</head>
<body data-theme="dark">
<div id="app" class="app-shell">
<app-sidebar class="sidebar"></app-sidebar>
<main id="main-pane" class="main-pane"></main>
<app-now-playing class="now-playing-footer"></app-now-playing>
<app-toast-host class="toast-host"></app-toast-host>
</div>
<script type="module" src="app.mjs"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
// Shared store: one mutable object + a subscribers list. The renderer
// registers components, and the polling loop calls `store.set(...)` which
// triggers a render for each subscriber.
const state = {
online: false,
nowPlaying: null,
queue: null,
downloads: null,
error: null,
route: '#library',
theme: localStorage.getItem('sjnmusic.theme') || 'dark',
toasts: [],
};
const subs = new Set();
export const store = {
state,
set(partial) {
Object.assign(state, partial);
for (const cb of subs) {
try { cb(state); } catch { /* defensive */ }
}
},
subscribe(cb) {
subs.add(cb);
cb(state);
return () => subs.delete(cb);
},
};
export function pushToast(message, level = 'info', ttlMs = 3500) {
const id = Math.random().toString(36).slice(2);
state.toasts.push({ id, message, level });
store.set({ toasts: [...state.toasts] });
if (ttlMs > 0) {
setTimeout(() => {
state.toasts = state.toasts.filter(t => t.id !== id);
store.set({ toasts: [...state.toasts] });
}, ttlMs);
}
}
+263
View File
@@ -0,0 +1,263 @@
/*
* sjnmusic GUI — hand-rolled CSS, dark theme default.
* Layout: grid with sidebar | main | footer-now-playing.
* Theme switching only flips CSS variables on `body[data-theme="..."]`.
*/
:root {
--bg-primary: #0f1115;
--bg-secondary: #161a20;
--bg-tertiary: #1f242c;
--bg-elev: #232830;
--border: #2a2f38;
--border-strong: #393f4a;
--text-primary: #e6e6e6;
--text-secondary: #999;
--text-muted: #6c727c;
--accent: #6cb2ff;
--accent-hover: #87c0ff;
--accent-pressed: #5496e0;
--danger: #ff6b6b;
--success: #4ed98c;
--warning: #ffb84d;
--shadow: 0 6px 24px rgba(0,0,0,0.35);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--sidebar-w: 220px;
--footer-h: 96px;
--row-pad-y: 4px;
--row-pad-x: 12px;
}
body[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f6f8;
--bg-tertiary: #ebedf1;
--bg-elev: #ffffff;
--border: #d6dae0;
--border-strong: #b3b9c4;
--text-primary: #1a1a1a;
--text-secondary: #555;
--text-muted: #888;
--accent: #2076d4;
--accent-hover: #1a5faf;
--accent-pressed: #145099;
--danger: #c94545;
--success: #2baf6c;
--warning: #cb7a14;
--shadow: 0 4px 18px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; margin: 0; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
overflow: hidden;
}
button {
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.1s ease, border-color 0.1s ease;
}
button:hover:not(:disabled) { background: var(--bg-tertiary); border-color: var(--border-strong); }
button:active:not(:disabled) { background: var(--bg-elev); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.danger { color: var(--danger); border-color: var(--border); }
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
input[type="text"], input[type="search"], select, textarea {
font: inherit;
color: inherit;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
outline: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
/* -------- shell layout -------- */
.app-shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: 1fr var(--footer-h);
height: 100vh;
}
.sidebar { grid-column: 1; grid-row: 1 / 3; background: var(--bg-secondary); border-right: 1px solid var(--border); }
.main-pane { grid-column: 2; grid-row: 1; overflow: auto; padding: 16px 24px; }
.now-playing-footer { grid-column: 2; grid-row: 2; background: var(--bg-secondary); border-top: 1px solid var(--border); }
.toast-host { position: fixed; bottom: 110px; right: 16px; z-index: 1000; }
/* -------- sidebar -------- */
.sidebar nav { padding: 12px 0; }
.sidebar .nav-link {
display: block;
padding: 8px 18px;
color: var(--text-secondary);
text-decoration: none;
border-left: 3px solid transparent;
transition: background-color 0.1s ease;
}
.sidebar .nav-link:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.sidebar .nav-link.active { background: var(--bg-tertiary); color: var(--accent); border-left-color: var(--accent); }
.sidebar .brand {
padding: 16px 18px 24px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text-primary);
}
.sidebar .offline-banner {
margin: 8px 12px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--warning);
}
/* -------- now-playing footer -------- */
.now-playing-footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
padding: 8px 24px;
}
.np-current { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np-current small { display: block; font-weight: 400; color: var(--text-secondary); }
.np-controls { display: flex; gap: 6px; align-items: center; }
.np-progress { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
.np-progress input[type=range] { width: 220px; }
.np-time { width: 96px; font-variant-numeric: tabular-nums; color: var(--text-secondary); text-align: right; }
.np-volume input[type=range] { width: 90px; vertical-align: middle; }
.icon-btn { padding: 4px 8px; min-width: 32px; }
/* -------- generic list rows -------- */
.row-list { display: flex; flex-direction: column; gap: 1px; }
.row-list .row {
display: grid;
grid-template-columns: 24px 1fr auto auto;
align-items: center;
gap: 12px;
padding: var(--row-pad-y) var(--row-pad-x);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.row-list .row:hover { background: var(--bg-tertiary); }
.row .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .meta { color: var(--text-secondary); font-size: 12px; }
.row.drop-target { box-shadow: inset 0 2px 0 var(--accent); }
.row.dragging { opacity: 0.4; }
/* -------- view-specific -------- */
.view-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px;
}
.view-header h1 { margin: 0; font-size: 22px; font-weight: 600; }
.view-search { flex: 1; }
.search-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.candidate {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
display: flex;
gap: 12px;
align-items: flex-start;
text-align: left;
cursor: pointer;
transition: border-color 0.1s ease, transform 0.05s ease;
}
.candidate:hover { border-color: var(--accent); }
.candidate img.thumb {
width: 64px; height: 64px; object-fit: cover;
border-radius: var(--radius-sm); background: var(--bg-tertiary);
flex: 0 0 auto;
}
.candidate .info { flex: 1; min-width: 0; }
.candidate .info .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.candidate .info .u { color: var(--text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.candidate .info .d { color: var(--text-muted); font-size: 11px; }
.downloads-table th { text-align: left; padding: 4px 8px; color: var(--text-secondary); font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border); }
.downloads-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.badge.running { color: var(--accent); border-color: var(--accent); }
.badge.done { color: var(--success); border-color: var(--success); }
.badge.failed { color: var(--danger); border-color: var(--danger); }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stats-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
}
.stats-card .label { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.stats-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
.theme-picker { display: flex; gap: 12px; }
.theme-picker button { padding: 6px 14px; }
.theme-picker button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* toast */
.toast {
background: var(--bg-elev);
color: var(--text-primary);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-top: 8px;
box-shadow: var(--shadow);
min-width: 200px;
max-width: 360px;
}
.toast.error { border-left-color: var(--danger); }
.toast.warn { border-left-color: var(--warning); }
/* responsive */
@media (max-width: 720px) {
:root { --sidebar-w: 56px; }
.sidebar .nav-label { display: none; }
.now-playing-footer { grid-template-columns: 1fr; gap: 6px; }
.now-playing-footer .np-progress, .np-volume { justify-self: stretch; }
}
+18
View File
@@ -0,0 +1,18 @@
// Theme management. Only two values: 'dark' (default) and 'light'. We
// store the choice in localStorage AND sync to the daemon HTTP read at
// startup if the daemon is reachable (deferred for v1).
import { store } from './store.mjs';
export const THEMES = ['dark', 'light'];
export function applyTheme(name) {
if (!THEMES.includes(name)) name = 'dark';
document.body.dataset.theme = name;
store.set({ theme: name });
try { localStorage.setItem('sjnmusic.theme', name); } catch { /* ignore */ }
}
export function cycleTheme() {
applyTheme(store.state.theme === 'dark' ? 'light' : 'dark');
}