add gui
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.log
|
||||
.DS_Store
|
||||
.electron-builder-cache/
|
||||
@@ -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', …)`.
|
||||
@@ -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;
|
||||
Generated
+5541
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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,'"')}">
|
||||
<span class="meta">▶</span>
|
||||
<div>
|
||||
<div class="title">${(s.name||'').replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[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 => ({'<':'<','>':'>','&':'&'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({ '<':'<','>':'>','&':'&','"':'"' }[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 => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||
function escapeAttr(s) { return String(s||'').replace(/"/g, '"'); }
|
||||
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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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, '"')}">
|
||||
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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
}
|
||||
}
|
||||
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user