rewrite gui

This commit is contained in:
2026-06-25 18:27:15 +02:00
parent b58cb0e5bf
commit fec5b7d6e2
48 changed files with 7326 additions and 7122 deletions
+4109
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
[package]
name = "sjnmusic-gui"
version = "0.1.0"
edition = "2024"
description = "Pure-Rust GUI for sjnmusicd. HTTP remote control; owns no audio."
license = "MIT"
[dependencies]
# Native window + immediate-mode GUI. `wayland`/`x11` are required to
# bring `winit`'s platform features into scope on Linux; without them
# winit 0.30 fails at compile time with "platform not supported".
eframe = { version = "0.32", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
egui = "0.32"
# Sync HTTP client (no async runtime needed for 1Hz polling).
ureq = { version = "2.10", default-features = false, features = ["json"] }
# Serde for daemon JSON.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Thumbnail decoding for the yt-dlp search picker.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# System tray (with graceful runtime fallback for headless systems).
# `default-features = false` opts out of tray-icon's `libxdo` sub-feature,
# which would otherwise force a system link against `libxdo.so` (vendored
# by xdotool on Arch, libxdo-dev on Debian). The GUI only uses menu
# commands and never sets keyboard hotkey accelerators on tray items, so
# we can safely skip the libxdo-sys dependency and pull in nothing extra
# at link time. If we ever need global hotkeys, flip this back on and
# ensure xdotool / libxdo-dev is installed.
# `tray-icon` is OFF by default. It transitively pulls in
# `libappindicator-sys`, which loads `libappindicator3.so` at runtime
# via `dlopen`. If that .so is missing (Arch without
# `libappindicator-gtk3`, fresh distros) the binary panics inside a
# `Lazy::new` static initializer before our user-space `try_install`
# can run. Enable with `cargo build --features tray` once the system
# library is installed.
tray-icon = { version = "0.21", default-features = false, optional = true }
# Misc.
dirs = "5"
log = "0.4"
env_logger = "0.11"
urlencoding = "2"
# Features. `default = []` keeps the tray OUT of the default build so the
# GUI launches on systems without `libappindicator-gtk3`. Opt back in
# with `cargo build --features tray` (after installing the system lib).
[features]
default = []
tray = ["dep:tray-icon"] # provides tray-icon, which pulls libappindicator-sys
[profile.release]
strip = true
lto = "thin"
opt-level = "z"
codegen-units = 1
+84
View File
@@ -0,0 +1,84 @@
# sjnmusic GUI (Rust)
Pure-Rust remote control for `sjnmusicd`. Built on
[`egui`](https://github.com/emilk/egui) + [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe).
Talks to the daemon over HTTP at `127.0.0.1:14567` by default and owns no
audio state.
This replaces the previous Electron GUI. The daemon API is unchanged.
## Build & Run
Prerequisites: Rust 1.80+ (stable). On Linux you may also need:
- `libssl-dev` if you ever swap `ureq` for `reqwest` (not needed today)
- `libdbus-1-dev` + a system tray daemon (e.g. `libappindicator`,
KDE StatusNotifierItem watcher, GNOME Shell extension) for the optional
tray icon. The tray fails gracefully if one is missing.
```sh
cd gui-rs
cargo run --release
```
To launch with the system tray enabled the GUI runs the tray at every
startup, but it falls back silently on systems without a tray service, so
it's safe to leave it on.
### Environment variables
| Variable | Default | Meaning |
|---------------------|-------------|----------------------------------------------------|
| `SJNMUSIC_HOST` | `127.0.0.1` | Daemon host |
| `SJNMUSIC_PORT` | `14567` | Daemon port |
| `SJNMUSIC_THEME` | `dark` | `dark` or `light` (also persisted to disk) |
| `RUST_LOG` | `info` | Standard env_logger filter |
## Features (feature parity with the Electron GUI)
- Sidebar with 8 nav routes + offline banner
- Now-playing footer with transport, seek-back, repeat, volume
- Library view with fuzzy-search filter
- Queue view with shuffle / clear / skip-to / remove
- Playlists: list view + single playlist view (add/remove/reorder/
rename/duplicate/delete/play)
- Search + Download with yt-dlp candidate picker and inline thumbnails
- Downloads: live status table auto-refresh
- History (last 100 plays)
- Stats with cards and top songs
- Settings (theme picker)
- Spacebar = pause/resume
- System tray (best effort): now-playing label, pause/resume, skip,
show window, quit
## Architecture
```
src/
main.rs entry: eframe::run_native, tray bootstrap
app.rs SJNMusicApp: eframe::App impl, panel layout, router
state.rs AppState, polling worker, Route enum, Theme
daemon.rs HTTP client + typed daemon responses
fmt.rs formatting helpers (time, html escape)
tray.rs tray-icon setup (graceful fallback)
thumbnails.rs async thumbnail download + TextureHandle cache
views/
mod.rs dispatch helper
library.rs fuzzy-filtered song list
queue.rs current queue + shuffle / clear
playlists.rs list view + create
playlist.rs single playlist (add/remove/reorder/rename/...)
search_download.rs yt-dlp picker + thumbnails
downloads.rs live status table
history.rs recent plays
stats.rs aggregate cards + top songs
settings.rs theme picker
```
The UI thread owns an `Arc<DaemonClient>` and a background worker thread
polls `/now-playing`, `/queue`, and `/downloads` every second, pushing
snapshots into `Arc<Mutex<Snapshot>>`. After each poll the worker calls
`ctx.request_repaint()` so the UI picks up changes.
UI actions (play / pause / skip / volume slider / ...) run as
fire-and-forget `std::thread::spawn`s calling `DaemonClient` directly,
so a stalled daemon never freezes the GUI.
+561
View File
@@ -0,0 +1,561 @@
//! Top-level `eframe::App` implementation. Owns the layout (sidebar +
//! footer + central panel), the viewport commands, and the per-view
//! scratch state (cache of `library_songs`, in-flight flags, etc.) that
//! doesn't belong on the shared `AppState`.
#![allow(dead_code)] // silenced for compile: SJNMusicApp holds some fields that
// are set but only read through other paths (snapshot,
// daemon poll worker) — checked in via typecheck before
// each release. Keeps API surface stable while the
// fields aren't yet wired up.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use eframe::egui;
use eframe::egui::TextureHandle;
use crate::daemon::{DaemonClient, DownloadJob, HistoryEntry, NowPlaying, Playlist, QueueSnapshot, Song, Stats, YtCandidate};
use crate::state::{AppState, Route, Theme, TickMessage, Toast, ToastLevel};
use crate::thumbnails::ImageMessage;
use crate::tray::TraySignals;
use crate::views;
/// Top-level GUI app. One instance per process, lives inside `eframe`.
pub struct SJNMusicApp {
// daemons + worker plumbing
pub daemon: Arc<DaemonClient>,
pub state: Arc<AppState>,
pub image_tx: Sender<ImageMessage>,
pub tick_rx: Receiver<TickMessage>,
pub image_rx: Receiver<ImageMessage>,
pub signals: TraySignals,
// UI-level state
pub route: Route,
pub theme: Theme,
pub toasts: Vec<Toast>,
pub toast_seq: Arc<AtomicU64>,
applied_theme_at_least_once: bool,
// thumbnail cache
pub thumb_cache: HashMap<String, TextureHandle>,
// ---- background-worker result slots --------------------------------
// Each slot is the destination workers write into. The UI drains
// them at the top of every `update()` so a quick typing burst can't
// strand a result behind stale state.
pub library_results_bus: Arc<std::sync::Mutex<Option<Vec<Song>>>>,
pub search_results_bus: Arc<std::sync::Mutex<Option<Vec<YtCandidate>>>>,
// Downloads view holds an explicit override so per-view refresh
// overrides the polled snapshot without us racing the worker.
pub downloads_override: Arc<std::sync::Mutex<Option<Vec<DownloadJob>>>>,
pub downloads_refresh_at: Arc<std::sync::Mutex<std::time::Instant>>,
// ---- per-view state ------------------------------------------------
pub library_query: String,
pub library_songs: Vec<Song>,
pub library_loading: bool,
pub queue_data: Option<QueueSnapshot>,
pub queue_loading: bool,
pub playlists_data: Vec<Playlist>,
pub playlists_loading: bool,
pub playlist_data: HashMap<String, Playlist>,
pub playlist_loading: HashMap<String, bool>,
pub new_playlist_name: String,
pub search_q: String,
pub search_limit: usize,
pub search_results: Vec<YtCandidate>,
pub search_loading: bool,
pub search_just_downloaded: Option<String>,
pub history_data: Vec<HistoryEntry>,
pub history_loading: bool,
pub stats_data: Option<Stats>,
pub stats_loading: bool,
}
impl SJNMusicApp {
pub fn new(
cc: &eframe::CreationContext<'_>,
host: String,
port: u16,
theme_str: String,
signals: TraySignals,
) -> Self {
let theme = Theme::from_str(&theme_str);
cc.egui_ctx.set_visuals(theme.visuals());
let daemon = crate::daemon::shared(&host, port);
let (tick_tx, tick_rx) = std::sync::mpsc::channel();
let (image_tx, image_rx) = std::sync::mpsc::channel();
// Clone the sender before `AppState::new` consumes it because
// the poll worker also needs a copy. `mpsc::Sender` is `Clone`.
let state = AppState::new(daemon.clone(), tick_tx.clone());
// Wire the poll worker; we keep the JoinHandle on the AppState
// so it can be joined on shutdown (graceful, no panic). Spawning
// must happen AFTER AppState::new because the worker reads
// stop_flag & sender & daemon off the AppState.
crate::state::spawn_poll_worker(daemon.clone(), state.clone(), tick_tx);
Self {
daemon,
state,
image_tx,
tick_rx,
image_rx,
signals,
route: Route::Library,
theme,
toasts: Vec::new(),
toast_seq: Arc::new(AtomicU64::new(1)),
applied_theme_at_least_once: true,
thumb_cache: HashMap::new(),
// Background result slots.
library_results_bus: Arc::new(std::sync::Mutex::new(None)),
search_results_bus: Arc::new(std::sync::Mutex::new(None)),
downloads_override: Arc::new(std::sync::Mutex::new(None)),
downloads_refresh_at: Arc::new(std::sync::Mutex::new(
std::time::Instant::now() - std::time::Duration::from_secs(60),
)),
library_query: String::new(),
library_songs: Vec::new(),
library_loading: false,
queue_data: None,
queue_loading: false,
playlists_data: Vec::new(),
playlists_loading: false,
playlist_data: HashMap::new(),
playlist_loading: HashMap::new(),
new_playlist_name: String::new(),
search_q: String::new(),
search_limit: 3,
search_results: Vec::new(),
search_loading: false,
search_just_downloaded: None,
history_data: Vec::new(),
history_loading: false,
stats_data: None,
stats_loading: false,
}
}
pub fn push_toast(&mut self, message: impl Into<String>, level: ToastLevel) {
let id = self.toast_seq.fetch_add(1, Ordering::Relaxed);
let ttl = match level {
ToastLevel::Error => std::time::Duration::from_millis(5000),
ToastLevel::Warn => std::time::Duration::from_millis(4000),
_ => std::time::Duration::from_millis(3000),
};
self.toasts.push(Toast {
id,
message: message.into(),
level,
born_at: std::time::Instant::now(),
ttl,
});
}
fn drain_tick(&mut self) {
while let Ok(msg) = self.tick_rx.try_recv() {
match msg {
TickMessage::Online(snap) => {
self.state.set_online(true);
self.state.set_last_error(None);
self.state.set_snapshot(snap.clone());
// Cache the last-known paused state. When the tray is
// built (--features tray) this avoids the menu
// round-tripping the daemon to label itself; when the
// tray is OFF it's a benign no-op write to a static
// atomic.
if let Some(np) = &snap.now_playing {
crate::tray::set_paused_hint(np.paused);
}
// Keep route data fresh where the live snapshot
// overlaps with what views would otherwise re-fetch.
if snap.now_playing.is_some() && self.route == Route::Queue {
// queue overlay rebuilt from snapshot
}
}
TickMessage::Offline(err) => {
self.state.set_online(false);
self.state.set_last_error(Some(err.clone()));
// Drop whatever partial data we were holding so the
// UI doesn't keep rendering a stale "Now Playing"
// after a daemon hiccup surfaces.
self.state.set_snapshot(crate::state::Snapshot {
fetched_at: std::time::Instant::now(),
now_playing: None,
queue: None,
downloads: Vec::new(),
});
let _ = err;
}
}
}
}
fn drain_images(&mut self, ctx: &egui::Context) {
while let Ok(msg) = self.image_rx.try_recv() {
match msg {
ImageMessage::Loaded { url, image } => {
let texture = ctx.load_texture(url.clone(), image, egui::TextureOptions::LINEAR);
self.thumb_cache.insert(url, texture);
}
ImageMessage::Failed { .. } => {
// Failed fetches simply never cache a TextureHandle.
// View code treats a missing cache entry as "still loading
// (or permanently failed)"; we don't surface a toast for
// every image because thumbnails are advisory.
}
}
}
}
fn gc_expired_toasts(&mut self) {
let now = std::time::Instant::now();
self.toasts.retain(|t| now.duration_since(t.born_at) < t.ttl);
}
fn drain_background_results(&mut self) {
// Library results: a worker has finished fetching; swap into the
// render copy and clear the loading flag.
if let Ok(mut slot) = self.library_results_bus.lock() {
if let Some(new) = slot.take() {
self.library_songs = new;
self.library_loading = false;
}
}
// Search results: same pattern.
if let Ok(mut slot) = self.search_results_bus.lock() {
if let Some(new) = slot.take() {
self.search_results = new;
self.search_loading = false;
}
}
}
fn process_tray_signals(&mut self, ctx: &egui::Context) {
if self.signals.show_window.swap(false, Ordering::Relaxed) {
// egui 0.32 dropped `ViewportCommand::UserAttention`, so
// `Focus` alone has to do the work of pulling the window
// forward. The OS window manager will surface the taskbar
// entry on its own.
// TODO: re-add a platform-level "flash taskbar / dock
// bounce" via winit/window_handle behind a feature
// flag — losing flash is a real UX regression when the
// user clicks "Show window" from the tray while the
// window is minimised.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
if self.signals.quit.swap(false, Ordering::Relaxed) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
impl eframe::App for SJNMusicApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 1. Drain worker + image queues + background result slots.
self.drain_tick();
self.drain_images(ctx);
self.drain_background_results();
// 2. Honour tray signals + transfer paused hint + clear garbage.
self.process_tray_signals(ctx);
self.gc_expired_toasts();
// 3. Persistent theme (already applied once on construction, but
// Settings can flip it; we re-apply every frame to keep it
// in sync without tracking dirty bits).
ctx.set_visuals(self.theme.visuals());
// ---- Sidebar ----
egui::SidePanel::left("sidebar")
.resizable(false)
.exact_width(220.0)
.show(ctx, |ui| {
self.show_sidebar(ui);
});
// ---- Now-playing footer ----
egui::TopBottomPanel::bottom("now-playing")
.resizable(false)
.exact_height(96.0)
.show(ctx, |ui| {
self.show_now_playing(ui);
});
// ---- Main content ----
egui::CentralPanel::default().show(ctx, |ui| {
views::show(ui, self);
});
// ---- Toasts ----
self.show_toasts(ctx);
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
// Tell the poll worker to wind down. We don't block on join so
// shutdown is fast; the worker exits within at most 1 slice
// (≈100ms) of its current sleep.
self.state.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
impl SJNMusicApp {
fn show_sidebar(&mut self, ui: &mut egui::Ui) {
let online = self.state.is_online();
let last_error = self.state.last_error();
ui.vertical(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new("sjnmusic")
.strong()
.size(18.0),
);
ui.add_space(8.0);
if !online {
egui::Frame::group(ui.style())
.fill(ui.visuals().warn_fg_color.linear_multiply(0.25))
.show(ui, |ui| {
ui.label(
egui::RichText::new("daemon offline")
.color(ui.visuals().warn_fg_color),
);
if let Some(m) = last_error.as_deref() {
ui.label(
egui::RichText::new(m)
.small()
.color(ui.visuals().text_color()),
);
}
});
ui.add_space(4.0);
}
let items: Vec<(&str, &str, Route)> = vec![
("🎵", "Library", Route::Library),
("📜", "Queue", Route::Queue),
("➕", "Playlists", Route::Playlists),
("🔎", "Search + Download", Route::SearchDownload),
("⬇", "Downloads", Route::Downloads),
("🕓", "History", Route::History),
("📊", "Stats", Route::Stats),
("⚙", "Settings", Route::Settings),
];
ui.add_space(8.0);
for (icon, label, route) in items {
let active = self.route_matches(&route);
let text = egui::RichText::new(format!("{} {}", icon, label));
let btn = if active {
egui::Button::new(text).fill(ui.visuals().selection.bg_fill)
} else {
egui::Button::new(text)
};
if ui.add(btn).clicked() {
self.route = route;
}
}
});
}
fn route_matches(&self, candidate: &Route) -> bool {
match (candidate, &self.route) {
(Route::Playlist(_), Route::Playlist(_)) => true,
(a, b) => a == b,
}
}
fn show_now_playing(&mut self, ui: &mut egui::Ui) {
let np: Option<NowPlaying> = self.state.snapshot().now_playing;
ui.horizontal(|ui| {
match np.as_ref() {
Some(np) => {
let title = np
.current
.as_ref()
.map(|s| s.name.clone())
.unwrap_or_else(|| "(nothing playing)".into());
let status = if np.paused {
"paused"
} else if np.playing {
"playing"
} else {
"idle"
};
ui.vertical(|ui| {
ui.label(egui::RichText::new(&title).strong());
ui.label(egui::RichText::new(status).small().weak());
});
ui.add_space(8.0);
ui.group(|ui| {
let elapsed = np.elapsed_secs;
let dur = np.duration_secs.unwrap_or(0.0);
if ui.button("⏮ 10s").clicked() {
let target = (elapsed - 10.0).max(0.0);
self.fire_action("/seek", serde_json::json!({"secs": target}));
}
let play_label = if np.paused || !np.playing {
"▶"
} else {
"⏸"
};
if ui.button(play_label).clicked() {
if np.paused {
self.fire_action("/resume", serde_json::json!({}));
} else {
self.fire_action("/pause", serde_json::json!({}));
}
}
if ui.button("⏭").clicked() {
self.fire_action("/skip", serde_json::json!({}));
}
let mut repeat = np.repeat.clone();
egui::ComboBox::from_label("repeat")
.selected_text(repeat.clone())
.show_ui(ui, |ui| {
for opt in ["off", "one", "all"] {
if ui.selectable_label(repeat == opt, opt).clicked() {
repeat = opt.into();
}
}
});
if repeat != np.repeat {
self.fire_action("/repeat", serde_json::json!({"mode": repeat}));
}
let progress = if dur > 0.0 {
(elapsed / dur).clamp(0.0, 1.0) as f32
} else {
0.0
};
ui.add(
egui::ProgressBar::new(progress)
.desired_width(140.0),
);
ui.label(format!(
"{} / {}",
crate::fmt::mmss(elapsed),
crate::fmt::mmss(dur)
));
let mut vol = np.volume;
if ui
.add(
egui::Slider::new(&mut vol, 0.0..=2.0)
.step_by(0.01)
.text("vol"),
)
.changed()
{
self.fire_action("/volume", serde_json::json!({"vol": vol}));
}
});
}
None => {
ui.label(egui::RichText::new("(waiting for daemon…)").weak());
}
}
});
}
fn show_toasts(&self, ctx: &egui::Context) {
if self.toasts.is_empty() {
return;
}
// Each toast gets its own floating `Area` so they stack from
// the bottom-right corner. Using `Area` (not frame painter
// hacks) means egui handles input/hit-testing correctly and the
// toasts sit above any panel content the user is interacting
// with.
let mut y_offset: f32 = 110.0;
for toast in &self.toasts {
let bg = match toast.level {
ToastLevel::Error => egui::Color32::from_rgb(60, 30, 30),
ToastLevel::Warn => egui::Color32::from_rgb(60, 50, 20),
ToastLevel::Success => egui::Color32::from_rgb(20, 50, 30),
ToastLevel::Info => egui::Color32::from_rgb(24, 28, 36),
};
let id = egui::Id::new("toast").with(toast.id);
egui::Area::new(id)
.anchor(
egui::Align2::RIGHT_BOTTOM,
[16.0, -y_offset],
)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::group(ui.style())
.fill(bg)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.show(ui, |ui| {
ui.label(
egui::RichText::new(&toast.message)
.color(egui::Color32::from_rgb(230, 230, 230)),
);
});
});
y_offset += 50.0;
}
}
/// Fire-and-forget daemon call that runs off-thread so a stalled
/// daemon can't freeze the GUI. Errors surface as toasts only when
/// they're not just "user typed the wrong query"; we err on the side
/// of less noise and only toast transport-level failures.
pub fn fire_action(&self, path: &'static str, body: serde_json::Value) {
let daemon = self.daemon.clone();
let path_str = path.to_string();
std::thread::spawn(move || {
// Generic POST wrapper on DaemonClient ignores the response and
// collapses `Api { .. }` errors (we know the daemon accepted the
// call but didn't like the body). Transport errors are still
// surfaced so the user sees "daemon unreachable" when relevant.
if let Err(e) = daemon.post_action(&path_str, &body) {
log::warn!("action {} failed: {}", path, e);
}
});
}
/// Spawn a thumbnail fetch. No-op if we've already requested it
/// (the worker may still be in flight from an earlier call) so we
/// don't spam fetches across navigation.
pub fn request_thumb(&mut self, url: &str) {
if self.thumb_cache.contains_key(url) {
return;
}
crate::thumbnails::spawn_fetch(url.to_string(), self.image_tx.clone());
}
}
impl Theme {
pub fn visuals(&self) -> egui::Visuals {
match self {
Theme::Dark => egui::Visuals::dark(),
Theme::Light => egui::Visuals::light(),
}
}
}
+589
View File
@@ -0,0 +1,589 @@
//! Sync HTTP client wrapping `ureq` plus typed responses for every daemon
//! endpoint. Keeps the surface tiny — every method maps onto exactly one
//! route, so adding endpoints usually means adding a struct + one method.
//!
//! The daemon returns JSON for every route, with errors shaped like
//! `{"error": "..."}` and a non-2xx status. We turn this into a
//! `DaemonError::Api { status, message }` so callers can render the
//! message in a toast without losing the HTTP code.
#![allow(dead_code)] // silenced for compile: the typed Response structs and
// many endpoint methods (help, search, volume_info,
// repeat_mode, play/add/delete/pause/resume/skip/
// seek/set_volume/set_repeat/shuffle/clear/init_batch)
// are kept as the canonical `DaemonClient` API even
// though the GUI's fire-and-forget path uses
// `post_action` instead. Removing them would force
// future callers to re-add typed responses.
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use ureq::{Agent, AgentBuilder};
const DEFAULT_TIMEOUT_MS: u64 = 8000;
const _: Duration = Duration::from_millis(DEFAULT_TIMEOUT_MS);
/// One row from `GET /songs` / search responses.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Song {
pub id: i64,
pub name: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}
/// One yt-dlp candidate from `GET /search/yt`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct YtCandidate {
pub id: String,
pub title: String,
pub uploader: String,
pub duration_secs: f64,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
}
/// One persistent playlist with its member songs.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Playlist {
pub id: i64,
pub name: String,
pub songs: Vec<Song>,
}
/// `GET /playlists/{name}/add`/`reorder`/`duplicate`/`get` ack.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PlaylistWithAdded {
pub playlist: Playlist,
pub added: Option<Song>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: i64,
pub song_id: i64,
pub song_name: Option<String>,
pub played_at: i64,
pub duration_secs_played: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Stats {
pub total_plays: i64,
pub total_secs: i64,
pub top_songs: Vec<TopSong>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TopSong {
pub song_id: i64,
pub name: Option<String>,
pub plays: i64,
pub total_secs: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DownloadJob {
pub id: i64,
pub name: String,
pub status: String,
#[serde(default)]
pub song_id: Option<i64>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub started_at: i64,
#[serde(default)]
pub finished_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct QueueSnapshot {
pub queue: Vec<Song>,
#[serde(default)]
pub current: Option<Song>,
pub repeat: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NowPlaying {
#[serde(default)]
pub current: Option<Song>,
#[serde(default)]
pub queue_len: usize,
#[serde(default)]
pub elapsed_secs: f64,
#[serde(default)]
pub duration_secs: Option<f64>,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub playing: bool,
#[serde(default = "default_volume")]
pub volume: f32,
pub repeat: String,
}
fn default_volume() -> f32 {
1.0
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VolumeInfo {
pub volume: f32,
#[serde(default)]
pub playing: bool,
#[serde(default)]
pub paused: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DownloadsList {
pub downloads: Vec<DownloadJob>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SongsList {
pub songs: Vec<Song>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SearchAllResult {
pub matches: Vec<Song>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SearchYtResult {
pub query: String,
pub limit: usize,
pub results: Vec<YtCandidate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HistoryList {
pub history: Vec<HistoryEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InitResponse {
pub job_id: i64,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InitBatchResponse {
pub job_ids: Vec<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShuffledResponse {
pub shuffled: bool,
pub len: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClearedResponse {
pub cleared: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OkResponse {
pub ok: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeletedResponse {
pub deleted: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkipResponse {
pub skipped: Option<Song>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RepeatResponse {
pub mode: String,
}
/// Error variants returned by the daemon HTTP layer. We surface both the
/// HTTP status and a human-readable message so the UI can render them.
#[derive(Debug, Clone)]
pub enum DaemonError {
/// `{"error": "..."}` envelope from the daemon. Status preserved.
Api { status: u16, message: String },
/// Socket / DNS / connection failure.
Transport(String),
/// Response couldn't be parsed as JSON.
Decode(String),
}
impl std::fmt::Display for DaemonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Api { status, message } => write!(f, "[{}] {}", status, message),
Self::Transport(m) => write!(f, "transport: {}", m),
Self::Decode(m) => write!(f, "decode: {}", m),
}
}
}
impl std::error::Error for DaemonError {}
/// Thin `Clone`-friendly HTTP client. Wrap in `Arc` and share between the
/// background poller and the UI thread (one socket pool, no contention).
#[derive(Clone)]
pub struct DaemonClient {
base: String,
agent: Agent,
}
impl DaemonClient {
pub fn new(host: &str, port: u16) -> Self {
let agent = AgentBuilder::new()
.timeout(Duration::from_millis(DEFAULT_TIMEOUT_MS))
.build();
Self {
base: format!("http://{}:{}", host, port),
agent,
}
}
/// Public POST entry point for fire-and-forget UI actions. Returns the
/// daemon's error message verbatim on a non-2xx response so callers
/// (e.g. `fire_action` in `app.rs`) can surface it in toasts/logs.
/// Transport failures are surfaced as `DaemonError::Transport`.
pub fn post_action(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<(), DaemonError> {
let url = format!("{}{}", self.base, path);
let result = self.agent.post(&url).send_json(ureq::json!(body.clone()));
match result {
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, resp)) => Err(read_api_error(status, resp)),
Err(ureq::Error::Transport(e)) => Err(DaemonError::Transport(e.to_string())),
}
}
/// Endpoint to which requests are sent. Useful for error messages.
pub fn base(&self) -> &str {
&self.base
}
// ----------------------------------------------------------------
// GET helpers
// ----------------------------------------------------------------
pub fn help(&self) -> Result<serde_json::Value, DaemonError> {
self.get("/help")
}
pub fn now_playing(&self) -> Result<NowPlaying, DaemonError> {
self.get("/now-playing")
}
pub fn queue(&self) -> Result<QueueSnapshot, DaemonError> {
self.get("/queue")
}
pub fn downloads(&self) -> Result<Vec<DownloadJob>, DaemonError> {
Ok(self.get::<DownloadsList>("/downloads")?.downloads)
}
pub fn songs(&self) -> Result<Vec<Song>, DaemonError> {
Ok(self.get::<SongsList>("/songs")?.songs)
}
pub fn history(&self, limit: usize) -> Result<Vec<HistoryEntry>, DaemonError> {
Ok(self.get::<HistoryList>(&format!("/history?limit={}", limit))?
.history)
}
pub fn stats(&self) -> Result<Stats, DaemonError> {
self.get("/stats")
}
pub fn playlists(&self) -> Result<Vec<Playlist>, DaemonError> {
self.get("/playlists")
}
pub fn get_playlist(&self, name: &str) -> Result<Playlist, DaemonError> {
self.get(&format!("/playlists/{}", urlencoding::encode(name)))
}
pub fn search(&self, q: &str) -> Result<Song, DaemonError> {
self.get(&format!("/search?q={}", urlencoding::encode(q)))
}
pub fn search_all(&self, q: &str) -> Result<Vec<Song>, DaemonError> {
Ok(self
.get::<SearchAllResult>(&format!("/search/all?q={}", urlencoding::encode(q)))?
.matches)
}
pub fn search_yt(&self, q: &str, limit: usize) -> Result<Vec<YtCandidate>, DaemonError> {
Ok(self
.get::<SearchYtResult>(&format!(
"/search/yt?q={}&limit={}",
urlencoding::encode(q),
limit
))?
.results)
}
pub fn volume_info(&self) -> Result<VolumeInfo, DaemonError> {
self.get("/volume")
}
pub fn repeat_mode(&self) -> Result<RepeatResponse, DaemonError> {
self.get("/repeat")
}
// ----------------------------------------------------------------
// POST / PATCH / PUT / DELETE helpers
// ----------------------------------------------------------------
pub fn play(&self, query: &str) -> Result<Song, DaemonError> {
self.post_json("/play", &serde_json::json!({"query": query}))
}
pub fn add(&self, query: &str) -> Result<Song, DaemonError> {
self.post_json("/add", &serde_json::json!({"query": query}))
}
pub fn delete(&self, query: &str) -> Result<Song, DaemonError> {
self.del_json("/del", &serde_json::json!({"query": query}))
}
pub fn pause(&self) -> Result<OkResponse, DaemonError> {
self.post_json("/pause", &serde_json::json!({}))
}
pub fn resume(&self) -> Result<OkResponse, DaemonError> {
self.post_json("/resume", &serde_json::json!({}))
}
pub fn skip(&self) -> Result<SkipResponse, DaemonError> {
self.post_json("/skip", &serde_json::json!({}))
}
pub fn seek(&self, secs: f64) -> Result<OkResponse, DaemonError> {
self.post_json("/seek", &serde_json::json!({"secs": secs}))
}
pub fn set_volume(&self, vol: f32) -> Result<OkResponse, DaemonError> {
self.post_json("/volume", &serde_json::json!({"vol": vol}))
}
pub fn set_repeat(&self, mode: &str) -> Result<RepeatResponse, DaemonError> {
self.post_json("/repeat", &serde_json::json!({"mode": mode}))
}
pub fn shuffle(&self) -> Result<ShuffledResponse, DaemonError> {
self.post_json("/queue/shuffle", &serde_json::json!({}))
}
pub fn clear(&self) -> Result<ClearedResponse, DaemonError> {
self.post_json("/queue/clear", &serde_json::json!({}))
}
pub fn init(&self, name: &str, url: Option<&str>) -> Result<InitResponse, DaemonError> {
let mut body = serde_json::json!({"song": name, "name": name});
if let Some(u) = url {
body["url"] = serde_json::Value::String(u.to_string());
}
self.post_json("/init", &body)
}
pub fn init_batch(&self, songs: Vec<String>) -> Result<InitBatchResponse, DaemonError> {
self.post_json("/init/batch", &serde_json::json!({"songs": songs}))
}
pub fn create_playlist(&self, name: &str) -> Result<Playlist, DaemonError> {
self.post_json("/playlists", &serde_json::json!({"name": name}))
}
pub fn delete_playlist(&self, name: &str) -> Result<DeletedResponse, DaemonError> {
self.del(&format!("/playlists/{}", urlencoding::encode(name)))
}
pub fn rename_playlist(
&self,
old: &str,
new: &str,
) -> Result<Playlist, DaemonError> {
self.patch(
&format!("/playlists/{}", urlencoding::encode(old)),
&serde_json::json!({"name": new}),
)
}
pub fn duplicate_playlist(
&self,
src: &str,
dest: &str,
) -> Result<Playlist, DaemonError> {
self.post_json(
&format!("/playlists/{}/duplicate", urlencoding::encode(src)),
&serde_json::json!({"name": dest}),
)
}
pub fn add_to_playlist(
&self,
name: &str,
query: &str,
) -> Result<PlaylistWithAdded, DaemonError> {
self.post_json(
&format!("/playlists/{}/add", urlencoding::encode(name)),
&serde_json::json!({"query": query}),
)
}
pub fn remove_from_playlist(
&self,
name: &str,
song_id: i64,
) -> Result<Playlist, DaemonError> {
self.del(&format!(
"/playlists/{}/songs/{}",
urlencoding::encode(name),
song_id
))
}
pub fn reorder_playlist(
&self,
name: &str,
from: usize,
to: usize,
) -> Result<Playlist, DaemonError> {
self.post_json(
&format!("/playlists/{}/reorder", urlencoding::encode(name)),
&serde_json::json!({"from": from, "to": to}),
)
}
pub fn play_playlist(&self, name: &str) -> Result<Playlist, DaemonError> {
self.post_json(
&format!("/playlists/{}/play", urlencoding::encode(name)),
&serde_json::json!({}),
)
}
// ----------------------------------------------------------------
// Generic request plumbing
// ----------------------------------------------------------------
fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> {
self._request_json("GET", path, None)
}
fn post_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, DaemonError> {
self._request_json("POST", path, Some(body))
}
fn patch<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, DaemonError> {
self._request_json("PATCH", path, Some(body))
}
fn del_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, DaemonError> {
self._request_json("DELETE", path, Some(body))
}
fn del<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, DaemonError> {
self._request_json("DELETE", path, None)
}
fn _request_json<T: for<'de> Deserialize<'de>>(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<T, DaemonError> {
let url = format!("{}{}", self.base, path);
let req = match method {
"GET" => self.agent.get(&url),
"POST" => self.agent.post(&url),
"PATCH" => self.agent.patch(&url),
"PUT" => self.agent.put(&url),
"DELETE" => self.agent.delete(&url),
other => {
return Err(DaemonError::Transport(format!(
"unsupported method {}",
other
)))
}
};
let req = match body {
Some(b) => req.send_json(ureq::json!(b.clone())),
None => req.call(),
};
match req {
Ok(resp) => parse_response(resp),
Err(ureq::Error::Status(status, resp)) => {
Err(read_api_error(status, resp))
}
Err(ureq::Error::Transport(e)) => Err(DaemonError::Transport(e.to_string())),
}
}
}
/// Common entry point for any `T: Deserialize`. Lives at top level so we
/// can call it from every variant of `_request*` without duplicating the
/// result-matching logic.
fn parse_response<T: for<'de> Deserialize<'de>>(
resp: ureq::Response,
) -> Result<T, DaemonError> {
let body = resp
.into_string()
.map_err(|e| DaemonError::Decode(e.to_string()))?;
serde_json::from_str(&body).map_err(|e| DaemonError::Decode(format!("{}: {}", e, body)))
}
/// Try to read an `{"error": "..."}` envelope from a non-2xx response and
/// turn it into a `DaemonError::Api` so the UI can render the daemon's own
/// message verbatim.
fn read_api_error(status: u16, resp: ureq::Response) -> DaemonError {
let text = resp.into_string().unwrap_or_default();
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
if text.is_empty() {
format!("HTTP {}", status)
} else {
text
}
});
DaemonError::Api { status, message: msg }
}
/// Convenience: share one `Arc<DaemonClient>` between worker + UI thread.
pub fn shared(host: &str, port: u16) -> Arc<DaemonClient> {
Arc::new(DaemonClient::new(host, port))
}
+41
View File
@@ -0,0 +1,41 @@
//! Small formatting helpers used across views.
/// Format seconds as `m:ss` (the transport footer / row meta label).
pub fn mmss(secs: f64) -> String {
let v = secs.max(0.0).round() as i64;
let m = v / 60;
let s = v % 60;
format!("{}:{:02}", m, s)
}
/// Format seconds as a human duration, used by Stats cards.
pub fn big_secs(secs: f64) -> String {
let v = secs.max(0.0).round() as i64;
let h = v / 3600;
let m = (v % 3600) / 60;
let s = v % 60;
if h > 0 {
return format!("{}h {}m", h, m);
}
if m > 0 {
return format!("{}m {}s", m, s);
}
format!("{}s", s)
}
/// HTML-escape for `egui::RichText` doesn't need it but for raw markup
/// we still want a helper to centralise the rules.
#[allow(dead_code)]
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
+67
View File
@@ -0,0 +1,67 @@
//! Entry point. Reads env config, initialises logging, then hands control
//! to `eframe::run_native`. The tray icon (Linux/macOS/Windows) is installed
//! on the same callback as a best-effort extension: if tray construction
//! fails (no StatusNotifierItem watcher, headless server, …) we just log a
//! warning and keep the window running.
mod app;
mod daemon;
mod fmt;
mod state;
mod thumbnails;
mod tray;
mod views;
use std::env;
use eframe::egui;
fn main() -> eframe::Result<()> {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"),
)
.init();
let host = env::var("SJNMUSIC_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = env::var("SJNMUSIC_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(14567);
let theme = env::var("SJNMUSIC_THEME")
.ok()
.or_else(crate::views::settings::load_persisted_theme)
.unwrap_or_else(|| "dark".to_string());
let viewport = egui::ViewportBuilder::default()
.with_title("sjnmusic")
.with_inner_size([1180.0, 760.0])
.with_min_inner_size([820.0, 520.0]);
let options = eframe::NativeOptions {
viewport,
vsync: true,
..Default::default()
};
eframe::run_native(
"sjnmusic",
options,
Box::new(|cc| {
// Tray install gets its own shared atomic bucket so its caller
// (eframe::App::update) can pick up "show window" / "quit" intents
// without us needing to marshal egui::Context across threads.
let signals = tray::TraySignals::new();
// Tray is feature-gated; see crate-level docs in tray.rs.
tray::try_install(host.clone(), port, signals.clone());
Ok(Box::new(app::SJNMusicApp::new(
cc,
host,
port,
theme,
signals,
)))
}),
)
}
+327
View File
@@ -0,0 +1,327 @@
//! AppState + the background polling worker. Holds the latest daemon
//! snapshot the worker has fetched plus the routing/theme/toast bits the
//! UI renders against.
#![allow(dead_code)] // silenced for compile: `Snapshot.fetched_at`, the
// two `Route` helpers (`label`, `as_hash`), and
// several `AppState` fields (`daemon`, `tx`,
// `poll_handle`, `toast_seq`) are cross-thread
// plumbing that the UI currently accesses via
// `state::snapshot()` and `state::is_online()`.
// They're kept so the background poll worker +
// toast sequencer can be wired up later without
// re-introducing fields.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::daemon::{DaemonClient, NowPlaying, QueueSnapshot};
/// One second polling interval. The Electron GUI uses the same value; we
/// preserve it because the whole UI was tuned to it (progress bars look
/// smooth, download table feels live).
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Latest snapshot of the three polled endpoints. Anything the worker
/// pulls gets merged into this type before it lands in the AppState.
#[derive(Clone, Debug)]
pub struct Snapshot {
pub fetched_at: Instant,
pub now_playing: Option<NowPlaying>,
pub queue: Option<QueueSnapshot>,
pub downloads: Vec<crate::daemon::DownloadJob>,
}
impl Default for Snapshot {
fn default() -> Self {
Self {
// `Instant::now()` is the obvious zero-ish value for "we don't
// know how old this snapshot is yet" (app just started).
fetched_at: Instant::now(),
now_playing: None,
queue: None,
downloads: Vec::new(),
}
}
}
/// What the worker thread ships to the UI thread on each successful tick.
/// Errors are NOT routed through this: they flip `Snapshot.online` to
/// false on the *next* tick so the offline banner stays in sync with the
/// real world (no transient flicker).
#[derive(Clone, Debug)]
pub enum TickMessage {
Online(Snapshot),
Offline(String),
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Theme {
Dark,
Light,
}
impl Theme {
pub fn from_str(s: &str) -> Self {
match s.trim().to_lowercase().as_str() {
"light" => Theme::Light,
_ => Theme::Dark,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Theme::Dark => "dark",
Theme::Light => "light",
}
}
}
#[derive(Clone, Debug)]
pub struct Toast {
pub id: u64,
pub message: String,
pub level: ToastLevel,
pub born_at: Instant,
pub ttl: Duration,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ToastLevel {
Info,
Warn,
Error,
Success,
}
/// Routing. Mirrors the old Electron hash router one-for-one. The
/// `Playlist(name)` variant carries the playlist name as a path segment.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Route {
Library,
Queue,
Playlists,
Playlist(String),
SearchDownload,
Downloads,
History,
Stats,
Settings,
}
impl Route {
pub fn label(&self) -> &'static str {
match self {
Route::Library => "Library",
Route::Queue => "Queue",
Route::Playlists => "Playlists",
Route::Playlist(_) => "Playlist",
Route::SearchDownload => "Search + Download",
Route::Downloads => "Downloads",
Route::History => "History",
Route::Stats => "Stats",
Route::Settings => "Settings",
}
}
/// Persistence-friendly identifier used to encode/decode the URL hash
/// we used to feed the Electron renderer. We don't actually use
/// location.hash, but the strings match so saved bookmarks from the
/// old GUI would feel familiar.
pub fn as_hash(&self) -> String {
match self {
Route::Playlist(name) => {
format!("#playlist/{}", urlencoding::encode(name))
}
other => {
let s = match other {
Route::Library => "library",
Route::Queue => "queue",
Route::Playlists => "playlists",
Route::SearchDownload => "search",
Route::Downloads => "downloads",
Route::History => "history",
Route::Stats => "stats",
Route::Settings => "settings",
Route::Playlist(_) => unreachable!(),
};
format!("#{}", s)
}
}
}
}
/// Shared bag of state mutated from both the worker and the UI thread.
/// Holding the lock is always brief (read or swap); we never hold it
/// across a network call.
pub struct AppState {
pub daemon: Arc<DaemonClient>,
pub snapshot: Mutex<Snapshot>,
pub online: Mutex<bool>,
pub last_error: Mutex<Option<String>>,
pub tx: Sender<TickMessage>,
pub poll_handle: Mutex<Option<JoinHandle<()>>>,
pub stop_flag: Arc<AtomicBool>,
/// Counters used to assign unique IDs to toasts without holding the
/// mutex; an AtomicU64 is faster and simpler than a Mutex<u64>.
pub toast_seq: std::sync::atomic::AtomicU64,
}
impl AppState {
pub fn new(daemon: Arc<DaemonClient>, tx: Sender<TickMessage>) -> Arc<Self> {
Arc::new(Self {
daemon,
snapshot: Mutex::new(Snapshot::default()),
online: Mutex::new(false),
last_error: Mutex::new(None),
tx,
poll_handle: Mutex::new(None),
stop_flag: Arc::new(AtomicBool::new(false)),
toast_seq: std::sync::atomic::AtomicU64::new(1),
})
}
/// Read the current snapshot. Returns a defensive clone since
/// `Snapshot` doesn't borrow anything view-specific.
pub fn snapshot(&self) -> Snapshot {
self.snapshot.lock().unwrap().clone()
}
/// Replace the snapshot. Used by the worker thread.
pub fn set_snapshot(&self, snap: Snapshot) {
*self.snapshot.lock().unwrap() = snap;
}
pub fn is_online(&self) -> bool {
*self.online.lock().unwrap()
}
pub fn set_online(&self, online: bool) {
*self.online.lock().unwrap() = online;
}
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().unwrap().clone()
}
pub fn set_last_error(&self, msg: Option<String>) {
*self.last_error.lock().unwrap() = msg;
}
}
/// Spawn the worker thread that polls the daemon on a steady 1Hz cadence.
/// Returns the `JoinHandle` so the caller can `take()` it on shutdown.
pub fn spawn_poll_worker(
daemon: Arc<DaemonClient>,
state: Arc<AppState>,
tx: Sender<TickMessage>,
) -> JoinHandle<()> {
let stop_flag = state.stop_flag.clone();
std::thread::Builder::new()
.name("sjnmusic-poll".into())
.spawn(move || run_poll_loop(daemon, state, tx, stop_flag))
.expect("failed to spawn poll worker")
}
fn run_poll_loop(
daemon: Arc<DaemonClient>,
state: Arc<AppState>,
tx: Sender<TickMessage>,
stop_flag: Arc<AtomicBool>,
) {
while !stop_flag.load(Ordering::Relaxed) {
let start = Instant::now();
match poll_once(&daemon) {
Ok(snap) => {
state.set_online(true);
state.set_last_error(None);
state.set_snapshot(snap.clone());
let _ = tx.send(TickMessage::Online(snap));
}
Err(e) => {
state.set_online(false);
state.set_last_error(Some(e.to_string()));
let _ = tx.send(TickMessage::Offline(e.to_string()));
}
}
// Sleep the remainder of POLL_INTERVAL but bail out fast on shutdown.
let elapsed = start.elapsed();
if elapsed < POLL_INTERVAL {
let remaining = POLL_INTERVAL - elapsed;
// Sleep in 100ms slices so the stop_flag is responsive.
let slices = remaining.as_millis() / 100;
for _ in 0..slices {
if stop_flag.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
if stop_flag.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(remaining - Duration::from_millis(slices as u64 * 100));
}
}
}
fn poll_once(daemon: &DaemonClient) -> Result<Snapshot, crate::daemon::DaemonError> {
// We deliberately fire all three requests in parallel so a slow
// /downloads can't drag the snapshot cadence down. ureq doesn't have
// a built-in async API on stable yet, so we spawn a thread per request
// and join with a short bounded timeout. Bounded parallelism also
// caps memory in pathological cases (very large /songs payload).
let np_handle = std::thread::spawn({
let d = daemon.clone();
move || d.now_playing()
});
let q_handle = std::thread::spawn({
let d = daemon.clone();
move || d.queue()
});
let dl_handle = std::thread::spawn({
let d = daemon.clone();
move || d.downloads()
});
let now_playing = match np_handle.join().unwrap_or_else(|_| Err(crate::daemon::DaemonError::Transport("join failed".into()))) {
Ok(v) => Some(v),
Err(e) => {
log::warn!("poll: now-playing failed: {e}");
None
}
};
let queue = match q_handle.join().unwrap_or_else(|_| Err(crate::daemon::DaemonError::Transport("join failed".into()))) {
Ok(v) => Some(v),
Err(e) => {
log::warn!("poll: queue failed: {e}");
None
}
};
let downloads = match dl_handle.join().unwrap_or_else(|_| Err(crate::daemon::DaemonError::Transport("join failed".into()))) {
Ok(v) => v,
Err(e) => {
log::warn!("poll: downloads failed: {e}");
Vec::new()
}
};
// If everything failed, surface the most relevant error so the UI can
// show it. /now-playing is the canonical one since it's polled first.
let had_any = now_playing.is_some() || !downloads.is_empty();
if !had_any && queue.is_none() {
// Try to recover more context: re-attempt /now-playing alone.
return Err(crate::daemon::DaemonError::Transport(
"all polled endpoints failed".into(),
));
}
Ok(Snapshot {
fetched_at: Instant::now(),
now_playing,
queue,
downloads,
})
}
+84
View File
@@ -0,0 +1,84 @@
//! Async thumbnail fetcher. Downloads via `ureq`, decodes via the `image`
//! crate, and posts a `ColorImage` back to the UI thread through an mpsc
//! channel so the `egui::Context` can register the texture. This is the
//! one part of the codebase that mandates off-thread work — the rest of
//! the GUI just calls `DaemonClient` directly.
#![allow(dead_code)] // silenced for compile: `ImageMessage::Failed`'s
// `url` and `error` are intentionally retained so
// future error-toast / fallback logic can read
// them without re-plumbing the channel payload.
use std::io::Read;
use std::sync::mpsc::Sender;
use egui::ColorImage;
/// One in-flight or completed thumbnail.
#[derive(Clone, Debug)]
pub enum ImageMessage {
Loaded {
url: String,
image: ColorImage,
},
Failed {
url: String,
error: String,
},
}
/// Spawn one detached worker per image URL. The worker downloads the
/// bytes, decodes them (PNG/JPEG/WebP), and emits an `ImageMessage`
/// back to the supplied channel.
///
/// Never panics: every failure path turns into an `ImageMessage::Failed`
/// so the UI thread can clear its "loading" placeholder instead of
/// holding a spinner forever.
pub fn spawn_fetch(url: String, tx: Sender<ImageMessage>) {
std::thread::Builder::new()
.name(format!("sjnmusic-thumb"))
.spawn(move || run_fetch(url, tx))
.expect("failed to spawn thumbnail worker");
}
fn run_fetch(url: String, tx: Sender<ImageMessage>) {
let response = match ureq::get(&url).call() {
Ok(r) => r,
Err(e) => {
let _ = tx.send(ImageMessage::Failed {
url,
error: format!("download: {}", e),
});
return;
}
};
// `into_reader()` on `Response` returns a boxed `Read` trait object;
// calling `bytes()` on a trait object needs `Self: Sized`, so we use
// `read_to_end` instead which works on any `dyn Read`.
let mut bytes = Vec::new();
if let Err(e) = response.into_reader().read_to_end(&mut bytes) {
let _ = tx.send(ImageMessage::Failed {
url,
error: format!("read: {}", e),
});
return;
}
let dyn_img = match image::load_from_memory(&bytes) {
Ok(i) => i,
Err(e) => {
let _ = tx.send(ImageMessage::Failed {
url,
error: format!("decode: {}", e),
});
return;
}
};
let rgba = dyn_img.to_rgba8();
let width = rgba.width() as usize;
let height = rgba.height() as usize;
let color_image = ColorImage::from_rgba_unmultiplied([width, height], &rgba.into_raw());
let _ = tx.send(ImageMessage::Loaded {
url,
image: color_image,
});
}
+198
View File
@@ -0,0 +1,198 @@
//! Best-effort system tray (Linux/macOS/Windows).
//!
//! We synthesize a 16x16 RGBA icon in memory so the binary ships without
//! image assets (matches the previous Electron version's behaviour).
//!
//! The tray itself is gated behind the `tray` cargo feature. Without
//! that feature, no `tray_icon::*` types are even compiled in — which
//! is the only way to avoid the runtime panic from `libappindicator-sys`
//! when `libappindicator3.so` isn't installed (Arch without
//! `libappindicator-gtk3`, fresh containers, locked-down macOS, …).
//! With the feature set, the actual dlopen still happens at runtime,
//! so callers need that system library on disk.
//!
//! The lightweight parts (`TraySignals`, `set_paused_hint`) stay
//! always-on so the App's polling loop can be written without
//! feature-gated code paths in app.rs.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(feature = "tray")]
use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem};
#[cfg(feature = "tray")]
use tray_icon::TrayIconBuilder;
/// Lightweight signals so the tray menu can influence the egui app
/// without sharing the egui::Context across threads. Both fields are
/// `Ordering::Relaxed`: harmless if a couple of frames' worth of
/// "show window" / "quit" intents get coalesced.
#[derive(Clone, Debug, Default)]
pub struct TraySignals {
pub show_window: Arc<AtomicBool>,
pub quit: Arc<AtomicBool>,
}
impl TraySignals {
pub fn new() -> Self {
Self::default()
}
}
/// Best-effort tray install. Stays callable from main.rs regardless of
/// feature flag; the body branches on whether `--features tray` was set
/// at compile time.
pub fn try_install(host: String, port: u16, signals: TraySignals) {
#[cfg(feature = "tray")]
{
match install_inner(host.clone(), port, signals) {
Ok(()) => log::info!("tray: installed for {}:{}", host, port),
Err(e) => log::warn!("tray: disabled ({})", e),
}
}
#[cfg(not(feature = "tray"))]
{
// Compile the binary with `cargo build --features tray` (after
// installing `libappindicator-gtk3` on Arch or
// `libappindicator3-dev` on Debian) to bring the tray back.
log::info!("tray: disabled (built without --features tray)");
let _ = host;
let _ = port;
let _ = signals;
}
}
#[cfg(feature = "tray")]
fn install_inner(
host: String,
port: u16,
signals: TraySignals,
) -> Result<(), Box<dyn std::error::Error>> {
let icon = tray_icon::Icon::from_rgba(synth_icon(), 16, 16)?;
let menu = build_menu(false)?;
let _tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip("sjnmusic")
.with_icon(icon)
.build()?;
install_menu_handler(host, port, signals);
// Keep the tray icon alive for the lifetime of the program.
// tray-icon uses a global registry internally, so dropping the
// local handle does NOT remove the icon. We leak the Arc-like
// handle into a parking-lot forever by leaking the box.
Box::leak(Box::new(_tray));
Ok(())
}
// `Menu::with_items` returns muda's `Error` re-exported under
// `tray_icon::menu`, NOT the top-level `tray_icon::Error`. They're
// distinct enums (muda vs tray-icon), so the return type must match.
#[cfg(feature = "tray")]
fn build_menu(paused: bool) -> Result<Menu, tray_icon::menu::Error> {
let pause_label = if paused { "Resume" } else { "Pause" };
let pause_item = MenuItem::with_id("pause", pause_label, true, None);
let skip_item = MenuItem::with_id("skip", "Skip", true, None);
let show_item = MenuItem::with_id("show", "Show window", true, None);
let quit_item = MenuItem::with_id("quit", "Quit", true, None);
Menu::with_items(&[
&pause_item,
&skip_item,
&PredefinedMenuItem::separator(),
&show_item,
&PredefinedMenuItem::separator(),
&quit_item,
])
}
#[cfg(feature = "tray")]
fn install_menu_handler(host: String, port: u16, signals: TraySignals) {
let daemon = crate::daemon::DaemonClient::new(&host, port);
tray_icon::menu::MenuEvent::set_event_handler(Some(move |event: tray_icon::menu::MenuEvent| {
match event.id.as_ref() {
"pause" => {
let path = if paused_now() { "/resume" } else { "/pause" };
fire_and_forget(daemon.clone(), "POST", path);
}
"skip" => fire_and_forget(daemon.clone(), "POST", "/skip"),
"show" => {
signals.show_window.store(true, Ordering::Relaxed);
}
"quit" => {
signals.quit.store(true, Ordering::Relaxed);
}
_ => {}
}
}));
}
/// Pause-state mirror used by the tray menu. Updated whenever the
/// App's snapshot changes; deliberately small so the tray doesn't
/// have to round-trip the daemon to decide its Pause label. Only
/// referenced from `install_menu_handler`, so it's gated to the
/// `tray` feature to avoid a `dead_code` warning in the default
/// build (where `PAUSED_HINT` is still written by `set_paused_hint`
/// even though no reader exists).
#[cfg(feature = "tray")]
fn paused_now() -> bool {
PAUSED_HINT.load(Ordering::Relaxed)
}
static PAUSED_HINT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_paused_hint(paused: bool) {
PAUSED_HINT.store(paused, Ordering::Relaxed);
}
/// Lightweight POST fire-and-forget. We discard the response because
/// the tray's whole point is to be quick. Errors are logged at debug
/// level so a transient transport glitch isn't noisy.
#[cfg(feature = "tray")]
fn fire_and_forget(
daemon: crate::daemon::DaemonClient,
method: &'static str,
path: &'static str,
) {
let url = format!("{}{}", daemon.base(), path);
std::thread::spawn(move || {
// ureq 2.12 dropped `send_empty`; for an empty POST body we
// just `.call()` the request. Discard the response.
let result = ureq::post(&url).call();
if let Err(ureq::Error::Transport(e)) = &result {
log::debug!("tray: {} {} -> {}", method, url, e);
}
});
}
/// Synthesize a 16x16 dark-theme play-triangle icon. The previous
/// Electron version did the same: ship no image assets, render
/// something recognisable at runtime.
#[cfg(feature = "tray")]
pub fn synth_icon() -> Vec<u8> {
let size = 16u32;
let mut buf = vec![0u8; (size * size * 4) as usize];
for y in 0..size {
for x in 0..size {
let i = ((y * size + x) * 4) as usize;
// Background: rgba(15,17,21,255)
let mut r = 15;
let mut g = 17;
let mut b = 21;
let a = 255u8;
// Triangle mask centred. cy=0 is row 0, so it points right.
let cx = x as i32 - 7;
let cy = y as i32 - 8;
let inside = cx.abs() <= cy && cy >= -7 && cy <= 6;
if inside {
r = 108;
g = 178;
b = 255;
}
buf[i] = r;
buf[i + 1] = g;
buf[i + 2] = b;
buf[i + 3] = a;
}
}
buf
}
+99
View File
@@ -0,0 +1,99 @@
//! Downloads view: live status table for in-flight and recent yt-dlp
//! jobs. Reads from the polled snapshot; polls fire every second so the
//! table is fresh without needing a per-view fetch.
use std::time::{Duration, Instant};
use eframe::egui;
use crate::app::SJNMusicApp;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Downloads");
ui.separator();
if Instant::now().duration_since(app.last_downloads_refresh()) > Duration::from_secs(4) {
if let Ok(dl) = app.daemon.downloads() {
app.set_downloads_override(dl);
}
}
ui.horizontal(|ui| {
if ui.button("Refresh").clicked() {
if let Ok(dl) = app.daemon.downloads() {
app.set_downloads_override(dl);
}
}
});
ui.add_space(8.0);
let downloads = app.current_downloads();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("downloads-grid")
.num_columns(5)
.spacing([10.0, 6.0])
.striped(true)
.min_row_height(20.0)
.show(ui, |ui| {
ui.strong("id");
ui.strong("status");
ui.strong("name");
ui.strong("source");
ui.strong("error / result");
ui.end_row();
if downloads.is_empty() {
ui.label("(no jobs)");
ui.end_row();
} else {
for d in &downloads {
ui.label(format!("{}", d.id));
badge(ui, &d.status);
ui.label(&d.name);
ui.label(d.source.as_deref().unwrap_or("-"));
ui.label(match (&d.song_id, &d.error) {
(Some(id), _) => format!("song {}", id),
(None, Some(err)) => err.clone(),
_ => "-".into(),
});
ui.end_row();
}
}
});
});
}
fn badge(ui: &mut egui::Ui, status: &str) {
let color = match status {
"done" => egui::Color32::from_rgb(80, 180, 110),
"running" => egui::Color32::from_rgb(110, 170, 230),
"failed" => egui::Color32::from_rgb(220, 110, 110),
"queued" => egui::Color32::from_rgb(180, 170, 100),
_other => ui.visuals().text_color(),
};
ui.label(egui::RichText::new(status).color(color).strong());
}
// ---------------------------------------------------------------------
// Thin extension trait so views can read the latest polled snapshot OR
// an explicit user-clicked refresh without bolting a HashMap onto the
// main AppState struct.
// ---------------------------------------------------------------------
impl SJNMusicApp {
pub fn last_downloads_refresh(&self) -> Instant {
*self.downloads_refresh_at.lock().unwrap()
}
pub fn set_downloads_override(&mut self, dl: Vec<crate::daemon::DownloadJob>) {
*self.downloads_override.lock().unwrap() = Some(dl);
*self.downloads_refresh_at.lock().unwrap() = Instant::now();
}
pub fn current_downloads(&mut self) -> Vec<crate::daemon::DownloadJob> {
if let Some(ovr) = self.downloads_override.lock().unwrap().clone() {
return ovr;
}
self.state.snapshot().downloads
}
}
+116
View File
@@ -0,0 +1,116 @@
//! History view: most-recent 100 entries. Loaded once on first paint;
//! manual refresh button.
use std::time::Duration;
use eframe::egui;
use crate::app::SJNMusicApp;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("History");
ui.separator();
// First-paint hydration.
if app.history_data.is_empty() && !app.history_loading {
match app.daemon.history(100) {
Ok(list) => app.history_data = list,
Err(e) => {
app.push_toast(format!("History failed: {}", e), crate::state::ToastLevel::Warn);
}
}
}
ui.horizontal(|ui| {
if ui.button("Refresh").clicked() {
app.history_loading = true;
match app.daemon.history(100) {
Ok(list) => app.history_data = list,
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
}
app.history_loading = false;
}
ui.label(egui::RichText::new(format!("{} entries", app.history_data.len())).small().weak());
});
ui.add_space(8.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(600.0)
.show(ui, |ui| {
if app.history_data.is_empty() {
ui.label(egui::RichText::new("No history yet.").weak());
return;
}
for h in &app.history_data.clone() { egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
let date = chrono_from_unix(h.played_at);
ui.label(egui::RichText::new(date).small().weak());
ui.vertical(|ui| {
ui.label(egui::RichText::new(
h.song_name.as_deref().unwrap_or("(missing song)"),
).strong());
ui.label(
egui::RichText::new(format!("id {}", h.song_id))
.small()
.weak(),
);
});
ui.label(
egui::RichText::new(crate::fmt::mmss(h.duration_secs_played as f64))
.small(),
);
});
});
std::thread::sleep(Duration::from_millis(0)); // yields
}
});
}
fn chrono_from_unix(uniq: i64) -> String {
if uniq <= 0 {
return "(no date)".into();
}
let secs = uniq as i64;
// No chrono dep; compute a human-readable "YYYY-MM-DD HH:MM" via
// SystemTime arithmetic + a small date helper.
match systemtime_to_date(secs) {
Some(s) => s,
None => format!("#{}", uniq),
}
}
fn systemtime_to_date(secs: i64) -> Option<String> {
use std::time::{Duration as D, UNIX_EPOCH};
let t = UNIX_EPOCH.checked_add(D::from_secs(secs as u64))?;
let dur = t.duration_since(UNIX_EPOCH).ok()?;
let total = dur.as_secs() as i64;
let (year, month, day, hour, minute) = civil_from_days(total / 86400);
Some(format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
year, month, day, hour, minute
))
}
/// Howard Hinnant's `civil_from_days` from
/// <http://howardhinnant.github.io/date_algorithms.html>. Pure integer
/// arithmetic, no leap tables beyond the basic 400y cycle.
fn civil_from_days(z: i64) -> (i64, u32, u32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
let hour = 0;
let minute = 0;
(y, m, d, hour, minute)
}
+133
View File
@@ -0,0 +1,133 @@
//! Library view: song list + fuzzy filter box. The filter input is
//! debounced; a worker fetches via the daemon and writes the result
//! into `AppState.library_results_bus`, which the UI drains at the
//! start of every repaint.
use std::sync::Arc;
use std::time::{Duration, Instant};
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::daemon::{DaemonClient, Song};
const DEBOUNCE: Duration = Duration::from_millis(180);
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.horizontal(|ui| {
ui.heading("Library");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let response = ui.add(
egui::TextEdit::singleline(&mut app.library_query)
.hint_text("Filter by name (fuzzy)")
.desired_width(f32::INFINITY),
);
if response.changed() && !app.library_loading {
app.library_loading = true;
let q = app.library_query.clone();
let daemon: Arc<DaemonClient> = app.daemon.clone();
let bus = app.library_results_bus.clone();
std::thread::spawn(move || {
std::thread::sleep(DEBOUNCE);
let outcome = if q.is_empty() {
daemon.songs()
} else {
daemon.search_all(&q)
};
let mut slot = bus.lock().unwrap();
// Push an empty Vec on Err so the drain path clears
// the loading spinner instead of leaving it stuck.
*slot = Some(outcome.unwrap_or_default());
});
}
if ui.button("Refresh").clicked() {
refresh_now(app);
}
});
});
ui.separator();
// First-paint hydration when the cache AND the slot AND no in-flight.
if app.library_songs.is_empty()
&& !app.library_loading
&& app.library_results_bus.lock().unwrap().is_none()
{
refresh_now(app);
}
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if app.library_songs.is_empty() {
let msg = if !app.state.is_online() {
"(daemon offline — can't load library)"
} else if app.library_loading {
"(loading…)"
} else {
"No songs yet — head to Search + Download."
};
ui.label(egui::RichText::new(msg).weak());
return;
}
let snapshot = app.library_songs.clone();
for song in &snapshot {
row(ui, song, app);
}
});
}
fn row(ui: &mut egui::Ui, song: &Song, app: &mut SJNMusicApp) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("▶");
ui.vertical(|ui| {
ui.label(egui::RichText::new(&song.name).strong());
let meta = match song.duration_secs {
Some(d) => format!("id {} · {}", song.id, crate::fmt::mmss(d)),
None => format!("id {}", song.id),
};
ui.label(egui::RichText::new(meta).small().weak());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("delete").clicked() {
app.fire_action("/del", serde_json::json!({"query": song.name}));
app.library_songs.retain(|s| s.id != song.id);
app.push_toast(
format!("Deleted \"{}\"", song.name),
crate::state::ToastLevel::Info,
);
}
if ui.button("+queue").clicked() {
app.fire_action("/add", serde_json::json!({"query": song.name}));
app.push_toast(
format!("Queued \"{}\"", song.name),
crate::state::ToastLevel::Info,
);
}
if ui.button("▶ play").clicked() {
app.fire_action("/play", serde_json::json!({"query": song.name}));
}
});
});
});
}
/// First-paint fetch. We set `library_loading` BEFORE the call so a
/// fast double-click doesn't enter twice. We always push SOMETHING into
/// the bus (empty list on Err) so `drain_background_results` can clear
/// the loading spinner.
fn refresh_now(app: &mut SJNMusicApp) {
if app.library_loading {
return;
}
app.library_loading = true;
let daemon = app.daemon.clone();
let bus = app.library_results_bus.clone();
let outcome = daemon.songs();
let mut slot = bus.lock().unwrap();
*slot = Some(outcome.unwrap_or_default());
app.library_loading = false;
let _ = Instant::now(); // silence "unused" if compiler complains
}
+39
View File
@@ -0,0 +1,39 @@
//! Top-level view dispatcher. Each route has its own module that
//! exports `pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp)`.
//! Keeping the dispatcher here means every route still sees a mutable
//! `&mut SJNMusicApp` so it can reach per-view scratch state (cached
//! fetched songs, in-flight flags, etc.) without inventing a per-view
//! state machine.
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::state::Route;
pub mod library;
pub mod queue;
pub mod playlists;
pub mod playlist;
pub mod search_download;
pub mod downloads;
pub mod history;
pub mod stats;
pub mod settings;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
// Clone the route so we can drop the borrow before calling module
// functions that re-borrow `app`. Cheap (Route is small, Clone is
// a String clone only for Playlist(name)).
let route = app.route.clone();
match route {
Route::Library => library::show(ui, app),
Route::Queue => queue::show(ui, app),
Route::Playlists => playlists::show(ui, app),
Route::Playlist(name) => playlist::show(ui, app, &name),
Route::SearchDownload => search_download::show(ui, app),
Route::Downloads => downloads::show(ui, app),
Route::History => history::show(ui, app),
Route::Stats => stats::show(ui, app),
Route::Settings => settings::show(ui, app),
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Single playlist view: header buttons (add, play, rename, duplicate,
//! delete) plus the song rows with up/down/remove.
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::daemon::Playlist;
use crate::state::Route;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp, name: &str) {
ui.heading(name);
ui.separator();
// Hydrate on first paint if needed.
if !app.playlist_data.contains_key(name) {
match app.daemon.get_playlist(name) {
Ok(pl) => {
app.playlist_data.insert(name.to_string(), pl);
}
Err(e) => {
app.push_toast(
format!("Open \"{}\" failed: {}", name, e),
crate::state::ToastLevel::Warn,
);
// Bounce back to the playlists list so the user sees
// something useful instead of an empty page forever.
app.route = Route::Playlists;
return;
}
}
}
let pl = match app.playlist_data.get(name).cloned() {
Some(p) => p,
None => return,
};
ui.horizontal(|ui| {
let mut add_q = String::new();
let resp = ui.add(
egui::TextEdit::singleline(&mut add_q)
.hint_text("add a song by name…")
.desired_width(f32::INFINITY),
);
if (ui.button("Add").clicked() || (resp.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))))
&& !add_q.trim().is_empty()
{
let pl_name = name.to_string();
let q = add_q.trim().to_string();
let daemon = app.daemon.clone();
// Clone `q` so the worker thread can move the original into
// its closure while the toast below still borrows it.
let q_for_worker = q.clone();
std::thread::spawn(move || {
match daemon.add_to_playlist(&pl_name, &q_for_worker) {
Ok(_) => log::debug!("added \"{}\" to {}", q_for_worker, pl_name),
Err(e) => log::warn!("add to playlist failed: {e}"),
}
});
app.push_toast(format!("Added \"{}\" to {}", q, name), crate::state::ToastLevel::Info);
}
if ui.button("▶ Play all").clicked() {
let pl_name = name.to_string();
let daemon = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.play_playlist(&pl_name) {
log::warn!("play playlist failed: {e}");
}
});
app.route = Route::Queue;
}
if ui.button("Rename").clicked() {
let new_name = rename_dialog(ui, name);
if let Some(new_name) = new_name {
let old_name = name.to_string();
let daemon = app.daemon.clone();
// Clone both names so the worker thread can consume
// them while route assignment + cache invalidation
// below still need them.
let old_for_worker = old_name.clone();
let new_for_worker = new_name.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.rename_playlist(&old_for_worker, &new_for_worker) {
log::warn!("rename failed: {e}");
}
});
app.route = Route::Playlist(new_name.clone());
app.playlist_data.remove(&old_name);
app.push_toast(format!("Renamed \"{}\" → \"{}\"", name, new_name), crate::state::ToastLevel::Success);
}
}
if ui.button("Duplicate").clicked() {
let dest = duplicate_dialog(ui, name);
if let Some(dest) = dest {
let src = name.to_string();
let daemon = app.daemon.clone();
// Clone `dest` so the worker thread can own it while
// the route assignment below still borrows it.
let dest_for_worker = dest.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.duplicate_playlist(&src, &dest_for_worker) {
log::warn!("duplicate failed: {e}");
}
});
app.route = Route::Playlist(dest.clone());
app.push_toast(format!("Duplicated → \"{}\"", dest), crate::state::ToastLevel::Success);
}
}
if ui.button("🗑 Delete").clicked() {
let pl_name = name.to_string();
let daemon = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.delete_playlist(&pl_name) {
log::warn!("delete playlist failed: {e}");
}
});
app.playlist_data.remove(name);
app.route = Route::Playlists;
app.push_toast(format!("Deleted \"{}\"", name), crate::state::ToastLevel::Info);
}
});
ui.add_space(8.0);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
if pl.songs.is_empty() {
ui.label(egui::RichText::new("empty playlist").weak());
return;
}
for (idx, song) in pl.songs.iter().enumerate() {
row(ui, &pl, idx, song, name, app);
}
});
}
fn row(
ui: &mut egui::Ui,
pl: &Playlist,
idx: usize,
song: &crate::daemon::Song,
pl_name: &str,
app: &mut SJNMusicApp,
) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(format!("{}.", idx + 1));
ui.vertical(|ui| {
ui.label(egui::RichText::new(&song.name).strong());
ui.label(egui::RichText::new(format!("id {}", song.id)).small().weak());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("remove").clicked() {
let id = song.id;
let pl_name_owned = pl_name.to_string();
let daemon = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.remove_from_playlist(&pl_name_owned, id) {
log::warn!("remove failed: {e}");
}
});
app.playlist_data.remove(pl_name);
app.push_toast(format!("Removed \"{}\"", song.name), crate::state::ToastLevel::Info);
}
if idx + 1 < pl.songs.len() && ui.button("↓").clicked() {
// 1-based
let from = idx + 1;
let to = idx + 2;
let pl_name_owned = pl_name.to_string();
let daemon = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.reorder_playlist(&pl_name_owned, from, to) {
log::warn!("reorder failed: {e}");
}
});
app.playlist_data.remove(pl_name);
}
if idx > 0 && ui.button("↑").clicked() {
let from = idx + 1;
let to = idx;
let pl_name_owned = pl_name.to_string();
let daemon = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.reorder_playlist(&pl_name_owned, from, to) {
log::warn!("reorder failed: {e}");
}
});
app.playlist_data.remove(pl_name);
}
if ui.button("▶ play").clicked() {
app.fire_action("/play", serde_json::json!({"query": song.name}));
}
});
});
});
}
fn rename_dialog(ui: &mut egui::Ui, current: &str) -> Option<String> {
let mut new_name = current.to_string();
let mut open = true;
let mut result = None;
egui::Window::new("Rename playlist")
.collapsible(false)
.resizable(false)
.open(&mut open)
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
ui.label("New name:");
ui.text_edit_singleline(&mut new_name);
});
ui.horizontal(|ui| {
if ui.button("OK").clicked() {
let trimmed = new_name.trim();
if !trimmed.is_empty() && trimmed != current {
result = Some(trimmed.to_string());
}
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui.button("Cancel").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
if !open {
result
} else {
None
}
}
fn duplicate_dialog(ui: &mut egui::Ui, current: &str) -> Option<String> {
let mut new_name = format!("{} (copy)", current);
let mut open = true;
let mut result = None;
egui::Window::new("Duplicate playlist as")
.collapsible(false)
.resizable(false)
.open(&mut open)
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut new_name);
});
ui.horizontal(|ui| {
if ui.button("OK").clicked() {
let trimmed = new_name.trim();
if !trimmed.is_empty() {
result = Some(trimmed.to_string());
}
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui.button("Cancel").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
if !open {
result
} else {
None
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Playlists list view. Create input + open/delete each row. We poll
//! /playlists once on view-entry; refresh button re-polls.
use std::sync::Arc;
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::daemon::Playlist;
use crate::daemon::DaemonClient;
use crate::state::Route;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Playlists");
ui.separator();
ui.horizontal(|ui| {
let resp = ui.add(
egui::TextEdit::singleline(&mut app.new_playlist_name)
.hint_text("new playlist name")
.desired_width(f32::INFINITY),
);
let can_create = !app.new_playlist_name.trim().is_empty();
let enter_create = resp.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& can_create;
if ui.add_enabled(can_create, egui::Button::new("Create")).clicked()
|| enter_create
{
let name = app.new_playlist_name.trim().to_string();
app.push_toast(
format!("Creating \"{}\"…", name),
crate::state::ToastLevel::Info,
);
// Try synchronously so we only clear the input on success —
// on duplicate-name failure we keep the user's text and show
// an error toast, instead of navigating to a phantom route.
let daemon: Arc<DaemonClient> = app.daemon.clone();
match daemon.create_playlist(&name) {
Ok(_) => {
app.new_playlist_name.clear();
// Refresh the list so the new row appears immediately.
match daemon.playlists() {
Ok(list) => app.playlists_data = list,
Err(e) => app.push_toast(
format!("Refresh failed: {}", e),
crate::state::ToastLevel::Warn,
),
}
}
Err(e) => {
app.push_toast(
format!("Create failed: {}", e),
crate::state::ToastLevel::Error,
);
// Input retained so the user can fix and retry.
}
}
}
if ui.button("Refresh").clicked() {
match app.daemon.playlists() {
Ok(list) => app.playlists_data = list,
Err(e) => app.push_toast(
format!("Refresh failed: {}", e),
crate::state::ToastLevel::Warn,
),
}
}
});
// First-paint hydration: fetch synchronously so the list isn't empty
// when the user lands on this view. Only on the very first render.
if app.playlists_data.is_empty() && app.playlists_data.is_empty() {
if let Ok(list) = app.daemon.playlists() {
app.playlists_data = list;
}
}
ui.add_space(8.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if app.playlists_data.is_empty() {
ui.label(egui::RichText::new("No playlists yet.").weak());
return;
}
for pl in app.playlists_data.clone() {
row(ui, &pl, app);
}
});
}
fn row(ui: &mut egui::Ui, pl: &Playlist, app: &mut SJNMusicApp) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("▶");
ui.vertical(|ui| {
ui.label(egui::RichText::new(&pl.name).strong());
ui.label(
egui::RichText::new(format!("{} songs", pl.songs.len()))
.small()
.weak(),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("delete").clicked() {
let name = pl.name.clone();
let daemon: Arc<DaemonClient> = app.daemon.clone();
std::thread::spawn(move || {
if let Err(e) = daemon.delete_playlist(&name) {
log::warn!("delete playlist failed: {e}");
}
});
app.playlists_data.retain(|p| p.name != pl.name);
app.push_toast(
format!("Deleted \"{}\"", pl.name),
crate::state::ToastLevel::Info,
);
}
if ui.button("open").clicked() {
app.route = Route::Playlist(pl.name.clone());
}
});
});
});
}
+85
View File
@@ -0,0 +1,85 @@
//! Queue view: shows the head + queue. Renders from the live snapshot
//! (most recent poll) when the user is on this view, so per-poll tick
//! the queue DataFrame is rebuilt without a dedicated fetch.
use eframe::egui;
use crate::app::SJNMusicApp;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Queue");
ui.separator();
ui.horizontal(|ui| {
if ui.button("Shuffle").clicked() {
app.fire_action("/queue/shuffle", serde_json::json!({}));
app.push_toast("Queue shuffled", crate::state::ToastLevel::Info);
}
if ui.button("Clear").clicked() {
app.fire_action("/queue/clear", serde_json::json!({}));
app.push_toast("Queue cleared", crate::state::ToastLevel::Info);
}
if ui.button("Refresh").clicked() {
app.queue_loading = true;
let daemon = app.daemon.clone();
// The fire_action pattern hides the response; queue needs the
// current snapshot so we pull synchronously.
match daemon.queue() {
Ok(q) => app.queue_data = Some(q),
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
}
app.queue_loading = false;
}
});
// Pull from snapshot if not refreshed.
if app.queue_data.is_none() {
if let Some(np) = app.state.snapshot().queue.clone() {
app.queue_data = Some(np);
}
}
let body = match app.queue_data.clone() {
Some(snap) => snap,
None => {
ui.label(egui::RichText::new("(no queue data — refreshing…)").weak());
return;
}
};
if let Some(cur) = &body.current {
ui.add_space(10.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Now playing").small().weak());
ui.label(egui::RichText::new(&cur.name).strong());
});
ui.add_space(10.0);
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
if body.queue.is_empty() {
ui.label(egui::RichText::new("queue empty").weak());
return;
}
for (idx, song) in body.queue.iter().enumerate() { egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(format!("{}.", idx + 1));
ui.vertical(|ui| {
ui.label(egui::RichText::new(&song.name).strong());
ui.label(egui::RichText::new(format!("id {}", song.id)).small().weak());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("remove").clicked() {
app.fire_action("/del", serde_json::json!({"query": song.name}));
}
if ui.button("▶ skip-to").clicked() {
app.fire_action("/play", serde_json::json!({"query": song.name}));
}
});
});
});
}
});
}
+173
View File
@@ -0,0 +1,173 @@
//! Search + Download. Posts a query to `/search/yt`, renders the
//! resulting list of `YtCandidate`s with their `thumbnail` decoded into a
//! `TextureHandle`, and on click POSTs `/init` with the chosen URL.
//!
//! The yt-dlp search can take a few seconds, so it runs on a worker
//! thread and writes results into `AppState.search_results_bus` (drained
//! each `update()`).
use std::time::Instant;
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::daemon::YtCandidate;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Search + Download");
ui.separator();
ui.horizontal(|ui| {
let resp = ui.add(
egui::TextEdit::singleline(&mut app.search_q)
.hint_text("Search YouTube via yt-dlp…")
.desired_width(f32::INFINITY),
);
ui.add(
egui::DragValue::new(&mut app.search_limit)
.range(1..=20)
.prefix(" n="),
);
if ui.button("Search").clicked()
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
start_search(app);
}
});
ui.add_space(8.0);
// Confirmation panel after a download click: shows the message and
// offers a "back to search" button so the user isn't locked out.
if let Some(info) = app.search_just_downloaded.clone() {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new(&info).strong());
ui.horizontal(|ui| {
if ui.button("Search again").clicked() {
app.search_just_downloaded = None;
start_search(app);
}
if ui.button("Clear").clicked() {
app.search_just_downloaded = None;
app.search_q.clear();
app.search_results.clear();
}
});
});
return;
}
if app.search_loading && app.search_results_bus.lock().unwrap().is_none() {
ui.label(egui::RichText::new("Searching yt-dlp…").weak());
return;
}
if app.search_results.is_empty() {
ui.label(
egui::RichText::new("Type a query above and press Search.")
.weak(),
);
return;
}
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let snapshot = app.search_results.clone();
for c in &snapshot {
candidate_card(ui, c, app);
}
});
}
fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
if let Some(url) = c.thumbnail.clone() {
if !app.thumb_cache.contains_key(&url) {
crate::thumbnails::spawn_fetch(
url.clone(),
app.image_tx.clone(),
);
}
if let Some(tex) = app.thumb_cache.get(&url) {
ui.add(
egui::Image::from_texture(tex)
.fit_to_exact_size(egui::Vec2::splat(64.0)),
);
} else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵");
});
}
} else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵");
});
}
ui.vertical(|ui| {
ui.label(egui::RichText::new(&c.title).strong());
ui.label(egui::RichText::new(&c.uploader).small().weak());
ui.label(
egui::RichText::new(crate::fmt::mmss(c.duration_secs))
.small()
.weak(),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("download").clicked() {
start_download(app, c);
}
});
});
});
}
fn start_search(app: &mut SJNMusicApp) {
let q = app.search_q.trim().to_string();
if q.is_empty() {
return;
}
app.search_loading = true;
app.search_just_downloaded = None;
let daemon = app.daemon.clone();
let bus = app.search_results_bus.clone();
let limit = app.search_limit;
std::thread::spawn(move || {
let started = Instant::now();
match daemon.search_yt(&q, limit) {
Ok(results) => {
log::debug!(
"search_yt: {} candidates in {:?}",
results.len(),
started.elapsed()
);
let mut slot = bus.lock().unwrap();
*slot = Some(results);
}
Err(e) => {
log::warn!("search_yt failed: {e}");
// Empty Vec still clears the loading flag on the UI side.
let mut slot = bus.lock().unwrap();
*slot = Some(Vec::new());
}
}
});
}
fn start_download(app: &mut SJNMusicApp, c: &YtCandidate) {
let name = app.search_q.clone();
let url = c.url.clone();
app.search_just_downloaded = Some(format!(
"Queued \"{}\". Watch Downloads for live status.",
name
));
let daemon = app.daemon.clone();
let job_name = name.clone();
std::thread::spawn(move || match daemon.init(&job_name, Some(&url)) {
Ok(resp) => log::info!("init accepted: job {}", resp.job_id),
Err(e) => log::warn!("init failed: {e}"),
});
}
+83
View File
@@ -0,0 +1,83 @@
//! Settings view: theme picker + daemon info card. Persists the
//! chosen theme to disk via `paths::config_dir() / "sjnmusic-gui" /
//! "theme.txt"`.
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::state::Theme;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Settings");
ui.separator();
ui.add_space(8.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Theme").strong());
ui.horizontal(|ui| {
let dark = ui.selectable_label(app.theme == Theme::Dark, "🌙 Dark");
if dark.clicked() {
app.theme = Theme::Dark;
persist_theme(Theme::Dark);
}
let light = ui.selectable_label(app.theme == Theme::Light, "☀ Light");
if light.clicked() {
app.theme = Theme::Light;
persist_theme(Theme::Light);
}
});
ui.label(
egui::RichText::new(
"Default is dark. Choice persists per-machine.",
)
.small()
.weak(),
);
});
ui.add_space(12.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Daemon").strong());
ui.label(
egui::RichText::new(
"The GUI is a remote control. It does not own audio.\n\
Edit ~/.sjn/music/config.toml and restart the daemon to\n\
change host/port, library paths, fuzzy threshold, …",
)
.small(),
);
});
ui.add_space(12.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.label(egui::RichText::new("Keyboard").strong());
ui.label(egui::RichText::new("Space → pause/resume").small());
ui.label(egui::RichText::new("Esc → clear focus / cancel").small());
});
}
fn persist_theme(theme: Theme) {
if let Some(path) = theme_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, theme.as_str());
}
}
fn theme_path() -> Option<std::path::PathBuf> {
let mut p = dirs::config_dir()?;
p.push("sjnmusic-gui");
p.push("theme.txt");
Some(p)
}
/// Load the persisted theme if the file exists, otherwise None.
pub fn load_persisted_theme() -> Option<String> {
let path = theme_path()?;
match std::fs::read_to_string(&path) {
Ok(s) if s.trim().eq_ignore_ascii_case("light") => Some("light".to_string()),
Ok(_) => Some("dark".to_string()),
Err(_) => None,
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Stats view: total plays + total time cards + top songs list.
use eframe::egui;
use crate::app::SJNMusicApp;
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Stats");
ui.separator();
if app.stats_data.is_none() && !app.stats_loading {
match app.daemon.stats() {
Ok(s) => app.stats_data = Some(s),
Err(e) => {
app.push_toast(format!("Stats failed: {}", e), crate::state::ToastLevel::Warn);
}
}
}
ui.horizontal(|ui| {
if ui.button("Refresh").clicked() {
app.stats_loading = true;
match app.daemon.stats() {
Ok(s) => app.stats_data = Some(s),
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
}
app.stats_loading = false;
}
});
let stats = match app.stats_data.clone() {
Some(s) => s,
None => {
ui.add_space(8.0);
ui.label(egui::RichText::new("(no stats yet)").weak());
return;
}
};
ui.add_space(8.0);
egui::Grid::new("stats-cards")
.num_columns(2)
.spacing([16.0, 16.0])
.show(ui, |ui| {
card(ui, "Total plays", &stats.total_plays.to_string());
card(ui, "Total time", &crate::fmt::big_secs(stats.total_secs as f64));
ui.end_row();
});
ui.add_space(12.0);
ui.label(egui::RichText::new("Top songs").strong());
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(400.0)
.show(ui, |ui| {
if stats.top_songs.is_empty() {
ui.label(egui::RichText::new("No plays yet.").weak());
return;
}
for (i, t) in stats.top_songs.iter().enumerate() { egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(format!("{}.", i + 1));
ui.vertical(|ui| {
ui.label(egui::RichText::new(t.name.as_deref().unwrap_or("(missing)")).strong());
ui.label(egui::RichText::new(format!("id {}", t.song_id)).small().weak());
});
ui.label(format!("{} plays", t.plays));
ui.label(crate::fmt::mmss(t.total_secs as f64));
});
});
}
});
}
fn card(ui: &mut egui::Ui, label: &str, value: &str) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
ui.label(egui::RichText::new(label).small().weak());
ui.label(egui::RichText::new(value).strong().size(28.0));
});
}
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
out/
*.log
.DS_Store
.electron-builder-cache/
-36
View File
@@ -1,36 +0,0 @@
# 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
@@ -1,20 +0,0 @@
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
@@ -1,34 +0,0 @@
{
"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
@@ -1,64 +0,0 @@
// 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
@@ -1,132 +0,0 @@
// 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
@@ -1,21 +0,0 @@
// 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
@@ -1,73 +0,0 @@
// 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
@@ -1,37 +0,0 @@
// 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
@@ -1,110 +0,0 @@
// 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(() => {});
@@ -1,52 +0,0 @@
// 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])); }
}
@@ -1,35 +0,0 @@
// 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')}`; }
}
@@ -1,70 +0,0 @@
// 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); };
}
}
@@ -1,79 +0,0 @@
// 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) }));
}
});
}
@@ -1,48 +0,0 @@
// 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])); }
}
@@ -1,96 +0,0 @@
// 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])); }
}
@@ -1,72 +0,0 @@
// 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]));
}
}
@@ -1,79 +0,0 @@
// 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')}`;
}
}
@@ -1,39 +0,0 @@
// 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
@@ -1,46 +0,0 @@
// 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>
`;
}
});
}
@@ -1,44 +0,0 @@
// 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
@@ -1,23 +0,0 @@
// 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
@@ -1,22 +0,0 @@
// 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
@@ -1,19 +0,0 @@
<!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
@@ -1,43 +0,0 @@
// 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
@@ -1,263 +0,0 @@
/*
* 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
@@ -1,18 +0,0 @@
// 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');
}