rewrite gui
This commit is contained in:
Generated
+4109
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}"),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.log
|
||||
.DS_Store
|
||||
.electron-builder-cache/
|
||||
@@ -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', …)`.
|
||||
@@ -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;
|
||||
Generated
-5541
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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,'"')}">
|
||||
<span class="meta">▶</span>
|
||||
<div>
|
||||
<div class="title">${(s.name||'').replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}</div>
|
||||
<div class="meta">id ${s.id}${dur}</div>
|
||||
</div>
|
||||
<button class="icon-btn" data-act="queue-add" title="Add to queue">+queue</button>
|
||||
<button class="icon-btn danger" data-act="delete" title="Delete">delete</button>
|
||||
</div>`;
|
||||
}).join('') || `<div class="meta">No songs yet — head to Search + Download.</div>`;
|
||||
root.innerHTML = items;
|
||||
root.querySelectorAll('.row').forEach(el => {
|
||||
el.addEventListener('click', (ev) => {
|
||||
const tgt = ev.target;
|
||||
if (tgt?.dataset?.act === 'queue-add') {
|
||||
window.api.post('/add', { body: { query: el.dataset.name } });
|
||||
} else if (tgt?.dataset?.act === 'delete') {
|
||||
window.api.del('/del', { body: { query: el.dataset.name } });
|
||||
} else {
|
||||
window.api.post('/play', { body: { query: el.dataset.name } });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
qBox?.addEventListener('input', debounce(fetchOnce, 180));
|
||||
fetchOnce();
|
||||
}
|
||||
_fmt(s) {
|
||||
s = Math.max(0, Math.round(s|0));
|
||||
const m = Math.floor(s/60), r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2,'0')}`;
|
||||
}
|
||||
});
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
}
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 => ({ '<':'<','>':'>','&':'&','"':'"' }[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 => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||
function escapeAttr(s) { return String(s||'').replace(/"/g, '"'); }
|
||||
function fmtSecs(s) {
|
||||
s = Math.max(0, Math.round(s|0));
|
||||
const m = Math.floor(s/60), r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2,'0')}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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, '"')}">
|
||||
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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
}
|
||||
}
|
||||
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user