rewrite gui

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