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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user