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