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
+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));
});
}