rewrite gui
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
//! Downloads view: live status table for in-flight and recent yt-dlp
|
||||
//! jobs. Reads from the polled snapshot; polls fire every second so the
|
||||
//! table is fresh without needing a per-view fetch.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Downloads");
|
||||
ui.separator();
|
||||
|
||||
if Instant::now().duration_since(app.last_downloads_refresh()) > Duration::from_secs(4) {
|
||||
if let Ok(dl) = app.daemon.downloads() {
|
||||
app.set_downloads_override(dl);
|
||||
}
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Refresh").clicked() {
|
||||
if let Ok(dl) = app.daemon.downloads() {
|
||||
app.set_downloads_override(dl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
let downloads = app.current_downloads();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("downloads-grid")
|
||||
.num_columns(5)
|
||||
.spacing([10.0, 6.0])
|
||||
.striped(true)
|
||||
.min_row_height(20.0)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("id");
|
||||
ui.strong("status");
|
||||
ui.strong("name");
|
||||
ui.strong("source");
|
||||
ui.strong("error / result");
|
||||
ui.end_row();
|
||||
if downloads.is_empty() {
|
||||
ui.label("(no jobs)");
|
||||
ui.end_row();
|
||||
} else {
|
||||
for d in &downloads {
|
||||
ui.label(format!("{}", d.id));
|
||||
badge(ui, &d.status);
|
||||
ui.label(&d.name);
|
||||
ui.label(d.source.as_deref().unwrap_or("-"));
|
||||
ui.label(match (&d.song_id, &d.error) {
|
||||
(Some(id), _) => format!("song {}", id),
|
||||
(None, Some(err)) => err.clone(),
|
||||
_ => "-".into(),
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn badge(ui: &mut egui::Ui, status: &str) {
|
||||
let color = match status {
|
||||
"done" => egui::Color32::from_rgb(80, 180, 110),
|
||||
"running" => egui::Color32::from_rgb(110, 170, 230),
|
||||
"failed" => egui::Color32::from_rgb(220, 110, 110),
|
||||
"queued" => egui::Color32::from_rgb(180, 170, 100),
|
||||
_other => ui.visuals().text_color(),
|
||||
};
|
||||
ui.label(egui::RichText::new(status).color(color).strong());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Thin extension trait so views can read the latest polled snapshot OR
|
||||
// an explicit user-clicked refresh without bolting a HashMap onto the
|
||||
// main AppState struct.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
impl SJNMusicApp {
|
||||
pub fn last_downloads_refresh(&self) -> Instant {
|
||||
*self.downloads_refresh_at.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_downloads_override(&mut self, dl: Vec<crate::daemon::DownloadJob>) {
|
||||
*self.downloads_override.lock().unwrap() = Some(dl);
|
||||
*self.downloads_refresh_at.lock().unwrap() = Instant::now();
|
||||
}
|
||||
|
||||
pub fn current_downloads(&mut self) -> Vec<crate::daemon::DownloadJob> {
|
||||
if let Some(ovr) = self.downloads_override.lock().unwrap().clone() {
|
||||
return ovr;
|
||||
}
|
||||
self.state.snapshot().downloads
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! History view: most-recent 100 entries. Loaded once on first paint;
|
||||
//! manual refresh button.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("History");
|
||||
ui.separator();
|
||||
|
||||
// First-paint hydration.
|
||||
if app.history_data.is_empty() && !app.history_loading {
|
||||
match app.daemon.history(100) {
|
||||
Ok(list) => app.history_data = list,
|
||||
Err(e) => {
|
||||
app.push_toast(format!("History failed: {}", e), crate::state::ToastLevel::Warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Refresh").clicked() {
|
||||
app.history_loading = true;
|
||||
match app.daemon.history(100) {
|
||||
Ok(list) => app.history_data = list,
|
||||
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
|
||||
}
|
||||
app.history_loading = false;
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("{} entries", app.history_data.len())).small().weak());
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.max_height(600.0)
|
||||
.show(ui, |ui| {
|
||||
if app.history_data.is_empty() {
|
||||
ui.label(egui::RichText::new("No history yet.").weak());
|
||||
return;
|
||||
}
|
||||
for h in &app.history_data.clone() { egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let date = chrono_from_unix(h.played_at);
|
||||
ui.label(egui::RichText::new(date).small().weak());
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(
|
||||
h.song_name.as_deref().unwrap_or("(missing song)"),
|
||||
).strong());
|
||||
ui.label(
|
||||
egui::RichText::new(format!("id {}", h.song_id))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::fmt::mmss(h.duration_secs_played as f64))
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(0)); // yields
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn chrono_from_unix(uniq: i64) -> String {
|
||||
if uniq <= 0 {
|
||||
return "(no date)".into();
|
||||
}
|
||||
let secs = uniq as i64;
|
||||
// No chrono dep; compute a human-readable "YYYY-MM-DD HH:MM" via
|
||||
// SystemTime arithmetic + a small date helper.
|
||||
match systemtime_to_date(secs) {
|
||||
Some(s) => s,
|
||||
None => format!("#{}", uniq),
|
||||
}
|
||||
}
|
||||
|
||||
fn systemtime_to_date(secs: i64) -> Option<String> {
|
||||
use std::time::{Duration as D, UNIX_EPOCH};
|
||||
let t = UNIX_EPOCH.checked_add(D::from_secs(secs as u64))?;
|
||||
let dur = t.duration_since(UNIX_EPOCH).ok()?;
|
||||
let total = dur.as_secs() as i64;
|
||||
let (year, month, day, hour, minute) = civil_from_days(total / 86400);
|
||||
Some(format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}",
|
||||
year, month, day, hour, minute
|
||||
))
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `civil_from_days` from
|
||||
/// <http://howardhinnant.github.io/date_algorithms.html>. Pure integer
|
||||
/// arithmetic, no leap tables beyond the basic 400y cycle.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
(y, m, d, hour, minute)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Library view: song list + fuzzy filter box. The filter input is
|
||||
//! debounced; a worker fetches via the daemon and writes the result
|
||||
//! into `AppState.library_results_bus`, which the UI drains at the
|
||||
//! start of every repaint.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::daemon::{DaemonClient, Song};
|
||||
|
||||
const DEBOUNCE: Duration = Duration::from_millis(180);
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Library");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.library_query)
|
||||
.hint_text("Filter by name (fuzzy)")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if response.changed() && !app.library_loading {
|
||||
app.library_loading = true;
|
||||
let q = app.library_query.clone();
|
||||
let daemon: Arc<DaemonClient> = app.daemon.clone();
|
||||
let bus = app.library_results_bus.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(DEBOUNCE);
|
||||
let outcome = if q.is_empty() {
|
||||
daemon.songs()
|
||||
} else {
|
||||
daemon.search_all(&q)
|
||||
};
|
||||
let mut slot = bus.lock().unwrap();
|
||||
// Push an empty Vec on Err so the drain path clears
|
||||
// the loading spinner instead of leaving it stuck.
|
||||
*slot = Some(outcome.unwrap_or_default());
|
||||
});
|
||||
}
|
||||
if ui.button("Refresh").clicked() {
|
||||
refresh_now(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// First-paint hydration when the cache AND the slot AND no in-flight.
|
||||
if app.library_songs.is_empty()
|
||||
&& !app.library_loading
|
||||
&& app.library_results_bus.lock().unwrap().is_none()
|
||||
{
|
||||
refresh_now(app);
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if app.library_songs.is_empty() {
|
||||
let msg = if !app.state.is_online() {
|
||||
"(daemon offline — can't load library)"
|
||||
} else if app.library_loading {
|
||||
"(loading…)"
|
||||
} else {
|
||||
"No songs yet — head to Search + Download."
|
||||
};
|
||||
ui.label(egui::RichText::new(msg).weak());
|
||||
return;
|
||||
}
|
||||
let snapshot = app.library_songs.clone();
|
||||
for song in &snapshot {
|
||||
row(ui, song, app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn row(ui: &mut egui::Ui, song: &Song, app: &mut SJNMusicApp) {
|
||||
egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("▶");
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&song.name).strong());
|
||||
let meta = match song.duration_secs {
|
||||
Some(d) => format!("id {} · {}", song.id, crate::fmt::mmss(d)),
|
||||
None => format!("id {}", song.id),
|
||||
};
|
||||
ui.label(egui::RichText::new(meta).small().weak());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("delete").clicked() {
|
||||
app.fire_action("/del", serde_json::json!({"query": song.name}));
|
||||
app.library_songs.retain(|s| s.id != song.id);
|
||||
app.push_toast(
|
||||
format!("Deleted \"{}\"", song.name),
|
||||
crate::state::ToastLevel::Info,
|
||||
);
|
||||
}
|
||||
if ui.button("+queue").clicked() {
|
||||
app.fire_action("/add", serde_json::json!({"query": song.name}));
|
||||
app.push_toast(
|
||||
format!("Queued \"{}\"", song.name),
|
||||
crate::state::ToastLevel::Info,
|
||||
);
|
||||
}
|
||||
if ui.button("▶ play").clicked() {
|
||||
app.fire_action("/play", serde_json::json!({"query": song.name}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// First-paint fetch. We set `library_loading` BEFORE the call so a
|
||||
/// fast double-click doesn't enter twice. We always push SOMETHING into
|
||||
/// the bus (empty list on Err) so `drain_background_results` can clear
|
||||
/// the loading spinner.
|
||||
fn refresh_now(app: &mut SJNMusicApp) {
|
||||
if app.library_loading {
|
||||
return;
|
||||
}
|
||||
app.library_loading = true;
|
||||
let daemon = app.daemon.clone();
|
||||
let bus = app.library_results_bus.clone();
|
||||
let outcome = daemon.songs();
|
||||
let mut slot = bus.lock().unwrap();
|
||||
*slot = Some(outcome.unwrap_or_default());
|
||||
app.library_loading = false;
|
||||
let _ = Instant::now(); // silence "unused" if compiler complains
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Top-level view dispatcher. Each route has its own module that
|
||||
//! exports `pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp)`.
|
||||
//! Keeping the dispatcher here means every route still sees a mutable
|
||||
//! `&mut SJNMusicApp` so it can reach per-view scratch state (cached
|
||||
//! fetched songs, in-flight flags, etc.) without inventing a per-view
|
||||
//! state machine.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::state::Route;
|
||||
|
||||
pub mod library;
|
||||
pub mod queue;
|
||||
pub mod playlists;
|
||||
pub mod playlist;
|
||||
pub mod search_download;
|
||||
pub mod downloads;
|
||||
pub mod history;
|
||||
pub mod stats;
|
||||
pub mod settings;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
// Clone the route so we can drop the borrow before calling module
|
||||
// functions that re-borrow `app`. Cheap (Route is small, Clone is
|
||||
// a String clone only for Playlist(name)).
|
||||
let route = app.route.clone();
|
||||
match route {
|
||||
Route::Library => library::show(ui, app),
|
||||
Route::Queue => queue::show(ui, app),
|
||||
Route::Playlists => playlists::show(ui, app),
|
||||
Route::Playlist(name) => playlist::show(ui, app, &name),
|
||||
Route::SearchDownload => search_download::show(ui, app),
|
||||
Route::Downloads => downloads::show(ui, app),
|
||||
Route::History => history::show(ui, app),
|
||||
Route::Stats => stats::show(ui, app),
|
||||
Route::Settings => settings::show(ui, app),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Single playlist view: header buttons (add, play, rename, duplicate,
|
||||
//! delete) plus the song rows with up/down/remove.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::daemon::Playlist;
|
||||
use crate::state::Route;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp, name: &str) {
|
||||
ui.heading(name);
|
||||
ui.separator();
|
||||
|
||||
// Hydrate on first paint if needed.
|
||||
if !app.playlist_data.contains_key(name) {
|
||||
match app.daemon.get_playlist(name) {
|
||||
Ok(pl) => {
|
||||
app.playlist_data.insert(name.to_string(), pl);
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_toast(
|
||||
format!("Open \"{}\" failed: {}", name, e),
|
||||
crate::state::ToastLevel::Warn,
|
||||
);
|
||||
// Bounce back to the playlists list so the user sees
|
||||
// something useful instead of an empty page forever.
|
||||
app.route = Route::Playlists;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pl = match app.playlist_data.get(name).cloned() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let mut add_q = String::new();
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut add_q)
|
||||
.hint_text("add a song by name…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if (ui.button("Add").clicked() || (resp.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter))))
|
||||
&& !add_q.trim().is_empty()
|
||||
{
|
||||
let pl_name = name.to_string();
|
||||
let q = add_q.trim().to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
// Clone `q` so the worker thread can move the original into
|
||||
// its closure while the toast below still borrows it.
|
||||
let q_for_worker = q.clone();
|
||||
std::thread::spawn(move || {
|
||||
match daemon.add_to_playlist(&pl_name, &q_for_worker) {
|
||||
Ok(_) => log::debug!("added \"{}\" to {}", q_for_worker, pl_name),
|
||||
Err(e) => log::warn!("add to playlist failed: {e}"),
|
||||
}
|
||||
});
|
||||
app.push_toast(format!("Added \"{}\" to {}", q, name), crate::state::ToastLevel::Info);
|
||||
}
|
||||
if ui.button("▶ Play all").clicked() {
|
||||
let pl_name = name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.play_playlist(&pl_name) {
|
||||
log::warn!("play playlist failed: {e}");
|
||||
}
|
||||
});
|
||||
app.route = Route::Queue;
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
let new_name = rename_dialog(ui, name);
|
||||
if let Some(new_name) = new_name {
|
||||
let old_name = name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
// Clone both names so the worker thread can consume
|
||||
// them while route assignment + cache invalidation
|
||||
// below still need them.
|
||||
let old_for_worker = old_name.clone();
|
||||
let new_for_worker = new_name.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.rename_playlist(&old_for_worker, &new_for_worker) {
|
||||
log::warn!("rename failed: {e}");
|
||||
}
|
||||
});
|
||||
app.route = Route::Playlist(new_name.clone());
|
||||
app.playlist_data.remove(&old_name);
|
||||
app.push_toast(format!("Renamed \"{}\" → \"{}\"", name, new_name), crate::state::ToastLevel::Success);
|
||||
}
|
||||
}
|
||||
if ui.button("Duplicate").clicked() {
|
||||
let dest = duplicate_dialog(ui, name);
|
||||
if let Some(dest) = dest {
|
||||
let src = name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
// Clone `dest` so the worker thread can own it while
|
||||
// the route assignment below still borrows it.
|
||||
let dest_for_worker = dest.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.duplicate_playlist(&src, &dest_for_worker) {
|
||||
log::warn!("duplicate failed: {e}");
|
||||
}
|
||||
});
|
||||
app.route = Route::Playlist(dest.clone());
|
||||
app.push_toast(format!("Duplicated → \"{}\"", dest), crate::state::ToastLevel::Success);
|
||||
}
|
||||
}
|
||||
if ui.button("🗑 Delete").clicked() {
|
||||
let pl_name = name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.delete_playlist(&pl_name) {
|
||||
log::warn!("delete playlist failed: {e}");
|
||||
}
|
||||
});
|
||||
app.playlist_data.remove(name);
|
||||
app.route = Route::Playlists;
|
||||
app.push_toast(format!("Deleted \"{}\"", name), crate::state::ToastLevel::Info);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if pl.songs.is_empty() {
|
||||
ui.label(egui::RichText::new("empty playlist").weak());
|
||||
return;
|
||||
}
|
||||
for (idx, song) in pl.songs.iter().enumerate() {
|
||||
row(ui, &pl, idx, song, name, app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn row(
|
||||
ui: &mut egui::Ui,
|
||||
pl: &Playlist,
|
||||
idx: usize,
|
||||
song: &crate::daemon::Song,
|
||||
pl_name: &str,
|
||||
app: &mut SJNMusicApp,
|
||||
) {
|
||||
egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{}.", idx + 1));
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&song.name).strong());
|
||||
ui.label(egui::RichText::new(format!("id {}", song.id)).small().weak());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("remove").clicked() {
|
||||
let id = song.id;
|
||||
let pl_name_owned = pl_name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.remove_from_playlist(&pl_name_owned, id) {
|
||||
log::warn!("remove failed: {e}");
|
||||
}
|
||||
});
|
||||
app.playlist_data.remove(pl_name);
|
||||
app.push_toast(format!("Removed \"{}\"", song.name), crate::state::ToastLevel::Info);
|
||||
}
|
||||
if idx + 1 < pl.songs.len() && ui.button("↓").clicked() {
|
||||
// 1-based
|
||||
let from = idx + 1;
|
||||
let to = idx + 2;
|
||||
let pl_name_owned = pl_name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.reorder_playlist(&pl_name_owned, from, to) {
|
||||
log::warn!("reorder failed: {e}");
|
||||
}
|
||||
});
|
||||
app.playlist_data.remove(pl_name);
|
||||
}
|
||||
if idx > 0 && ui.button("↑").clicked() {
|
||||
let from = idx + 1;
|
||||
let to = idx;
|
||||
let pl_name_owned = pl_name.to_string();
|
||||
let daemon = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.reorder_playlist(&pl_name_owned, from, to) {
|
||||
log::warn!("reorder failed: {e}");
|
||||
}
|
||||
});
|
||||
app.playlist_data.remove(pl_name);
|
||||
}
|
||||
if ui.button("▶ play").clicked() {
|
||||
app.fire_action("/play", serde_json::json!({"query": song.name}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn rename_dialog(ui: &mut egui::Ui, current: &str) -> Option<String> {
|
||||
let mut new_name = current.to_string();
|
||||
let mut open = true;
|
||||
let mut result = None;
|
||||
egui::Window::new("Rename playlist")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("New name:");
|
||||
ui.text_edit_singleline(&mut new_name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
let trimmed = new_name.trim();
|
||||
if !trimmed.is_empty() && trimmed != current {
|
||||
result = Some(trimmed.to_string());
|
||||
}
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
result
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_dialog(ui: &mut egui::Ui, current: &str) -> Option<String> {
|
||||
let mut new_name = format!("{} (copy)", current);
|
||||
let mut open = true;
|
||||
let mut result = None;
|
||||
egui::Window::new("Duplicate playlist as")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(&mut new_name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
let trimmed = new_name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result = Some(trimmed.to_string());
|
||||
}
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
result
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Playlists list view. Create input + open/delete each row. We poll
|
||||
//! /playlists once on view-entry; refresh button re-polls.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::daemon::Playlist;
|
||||
use crate::daemon::DaemonClient;
|
||||
use crate::state::Route;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Playlists");
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.new_playlist_name)
|
||||
.hint_text("new playlist name")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
let can_create = !app.new_playlist_name.trim().is_empty();
|
||||
let enter_create = resp.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
&& can_create;
|
||||
if ui.add_enabled(can_create, egui::Button::new("Create")).clicked()
|
||||
|| enter_create
|
||||
{
|
||||
let name = app.new_playlist_name.trim().to_string();
|
||||
app.push_toast(
|
||||
format!("Creating \"{}\"…", name),
|
||||
crate::state::ToastLevel::Info,
|
||||
);
|
||||
// Try synchronously so we only clear the input on success —
|
||||
// on duplicate-name failure we keep the user's text and show
|
||||
// an error toast, instead of navigating to a phantom route.
|
||||
let daemon: Arc<DaemonClient> = app.daemon.clone();
|
||||
match daemon.create_playlist(&name) {
|
||||
Ok(_) => {
|
||||
app.new_playlist_name.clear();
|
||||
// Refresh the list so the new row appears immediately.
|
||||
match daemon.playlists() {
|
||||
Ok(list) => app.playlists_data = list,
|
||||
Err(e) => app.push_toast(
|
||||
format!("Refresh failed: {}", e),
|
||||
crate::state::ToastLevel::Warn,
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_toast(
|
||||
format!("Create failed: {}", e),
|
||||
crate::state::ToastLevel::Error,
|
||||
);
|
||||
// Input retained so the user can fix and retry.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Refresh").clicked() {
|
||||
match app.daemon.playlists() {
|
||||
Ok(list) => app.playlists_data = list,
|
||||
Err(e) => app.push_toast(
|
||||
format!("Refresh failed: {}", e),
|
||||
crate::state::ToastLevel::Warn,
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// First-paint hydration: fetch synchronously so the list isn't empty
|
||||
// when the user lands on this view. Only on the very first render.
|
||||
if app.playlists_data.is_empty() && app.playlists_data.is_empty() {
|
||||
if let Ok(list) = app.daemon.playlists() {
|
||||
app.playlists_data = list;
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if app.playlists_data.is_empty() {
|
||||
ui.label(egui::RichText::new("No playlists yet.").weak());
|
||||
return;
|
||||
}
|
||||
for pl in app.playlists_data.clone() {
|
||||
row(ui, &pl, app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn row(ui: &mut egui::Ui, pl: &Playlist, app: &mut SJNMusicApp) {
|
||||
egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("▶");
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&pl.name).strong());
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} songs", pl.songs.len()))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("delete").clicked() {
|
||||
let name = pl.name.clone();
|
||||
let daemon: Arc<DaemonClient> = app.daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = daemon.delete_playlist(&name) {
|
||||
log::warn!("delete playlist failed: {e}");
|
||||
}
|
||||
});
|
||||
app.playlists_data.retain(|p| p.name != pl.name);
|
||||
app.push_toast(
|
||||
format!("Deleted \"{}\"", pl.name),
|
||||
crate::state::ToastLevel::Info,
|
||||
);
|
||||
}
|
||||
if ui.button("open").clicked() {
|
||||
app.route = Route::Playlist(pl.name.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Queue view: shows the head + queue. Renders from the live snapshot
|
||||
//! (most recent poll) when the user is on this view, so per-poll tick
|
||||
//! the queue DataFrame is rebuilt without a dedicated fetch.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Queue");
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Shuffle").clicked() {
|
||||
app.fire_action("/queue/shuffle", serde_json::json!({}));
|
||||
app.push_toast("Queue shuffled", crate::state::ToastLevel::Info);
|
||||
}
|
||||
if ui.button("Clear").clicked() {
|
||||
app.fire_action("/queue/clear", serde_json::json!({}));
|
||||
app.push_toast("Queue cleared", crate::state::ToastLevel::Info);
|
||||
}
|
||||
if ui.button("Refresh").clicked() {
|
||||
app.queue_loading = true;
|
||||
let daemon = app.daemon.clone();
|
||||
// The fire_action pattern hides the response; queue needs the
|
||||
// current snapshot so we pull synchronously.
|
||||
match daemon.queue() {
|
||||
Ok(q) => app.queue_data = Some(q),
|
||||
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
|
||||
}
|
||||
app.queue_loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Pull from snapshot if not refreshed.
|
||||
if app.queue_data.is_none() {
|
||||
if let Some(np) = app.state.snapshot().queue.clone() {
|
||||
app.queue_data = Some(np);
|
||||
}
|
||||
}
|
||||
|
||||
let body = match app.queue_data.clone() {
|
||||
Some(snap) => snap,
|
||||
None => {
|
||||
ui.label(egui::RichText::new("(no queue data — refreshing…)").weak());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cur) = &body.current {
|
||||
ui.add_space(10.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Now playing").small().weak());
|
||||
ui.label(egui::RichText::new(&cur.name).strong());
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if body.queue.is_empty() {
|
||||
ui.label(egui::RichText::new("queue empty").weak());
|
||||
return;
|
||||
}
|
||||
for (idx, song) in body.queue.iter().enumerate() { egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{}.", idx + 1));
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&song.name).strong());
|
||||
ui.label(egui::RichText::new(format!("id {}", song.id)).small().weak());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("remove").clicked() {
|
||||
app.fire_action("/del", serde_json::json!({"query": song.name}));
|
||||
}
|
||||
if ui.button("▶ skip-to").clicked() {
|
||||
app.fire_action("/play", serde_json::json!({"query": song.name}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Search + Download. Posts a query to `/search/yt`, renders the
|
||||
//! resulting list of `YtCandidate`s with their `thumbnail` decoded into a
|
||||
//! `TextureHandle`, and on click POSTs `/init` with the chosen URL.
|
||||
//!
|
||||
//! The yt-dlp search can take a few seconds, so it runs on a worker
|
||||
//! thread and writes results into `AppState.search_results_bus` (drained
|
||||
//! each `update()`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::daemon::YtCandidate;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Search + Download");
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.search_q)
|
||||
.hint_text("Search YouTube via yt-dlp…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut app.search_limit)
|
||||
.range(1..=20)
|
||||
.prefix(" n="),
|
||||
);
|
||||
if ui.button("Search").clicked()
|
||||
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
{
|
||||
start_search(app);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Confirmation panel after a download click: shows the message and
|
||||
// offers a "back to search" button so the user isn't locked out.
|
||||
if let Some(info) = app.search_just_downloaded.clone() {
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(&info).strong());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Search again").clicked() {
|
||||
app.search_just_downloaded = None;
|
||||
start_search(app);
|
||||
}
|
||||
if ui.button("Clear").clicked() {
|
||||
app.search_just_downloaded = None;
|
||||
app.search_q.clear();
|
||||
app.search_results.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if app.search_loading && app.search_results_bus.lock().unwrap().is_none() {
|
||||
ui.label(egui::RichText::new("Searching yt-dlp…").weak());
|
||||
return;
|
||||
}
|
||||
|
||||
if app.search_results.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new("Type a query above and press Search.")
|
||||
.weak(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
let snapshot = app.search_results.clone();
|
||||
for c in &snapshot {
|
||||
candidate_card(ui, c, app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
|
||||
egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(url) = c.thumbnail.clone() {
|
||||
if !app.thumb_cache.contains_key(&url) {
|
||||
crate::thumbnails::spawn_fetch(
|
||||
url.clone(),
|
||||
app.image_tx.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(tex) = app.thumb_cache.get(&url) {
|
||||
ui.add(
|
||||
egui::Image::from_texture(tex)
|
||||
.fit_to_exact_size(egui::Vec2::splat(64.0)),
|
||||
);
|
||||
} else {
|
||||
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
|
||||
ui.label("🎵");
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
|
||||
ui.label("🎵");
|
||||
});
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&c.title).strong());
|
||||
ui.label(egui::RichText::new(&c.uploader).small().weak());
|
||||
ui.label(
|
||||
egui::RichText::new(crate::fmt::mmss(c.duration_secs))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("download").clicked() {
|
||||
start_download(app, c);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn start_search(app: &mut SJNMusicApp) {
|
||||
let q = app.search_q.trim().to_string();
|
||||
if q.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.search_loading = true;
|
||||
app.search_just_downloaded = None;
|
||||
let daemon = app.daemon.clone();
|
||||
let bus = app.search_results_bus.clone();
|
||||
let limit = app.search_limit;
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
match daemon.search_yt(&q, limit) {
|
||||
Ok(results) => {
|
||||
log::debug!(
|
||||
"search_yt: {} candidates in {:?}",
|
||||
results.len(),
|
||||
started.elapsed()
|
||||
);
|
||||
let mut slot = bus.lock().unwrap();
|
||||
*slot = Some(results);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("search_yt failed: {e}");
|
||||
// Empty Vec still clears the loading flag on the UI side.
|
||||
let mut slot = bus.lock().unwrap();
|
||||
*slot = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_download(app: &mut SJNMusicApp, c: &YtCandidate) {
|
||||
let name = app.search_q.clone();
|
||||
let url = c.url.clone();
|
||||
app.search_just_downloaded = Some(format!(
|
||||
"Queued \"{}\". Watch Downloads for live status.",
|
||||
name
|
||||
));
|
||||
let daemon = app.daemon.clone();
|
||||
let job_name = name.clone();
|
||||
std::thread::spawn(move || match daemon.init(&job_name, Some(&url)) {
|
||||
Ok(resp) => log::info!("init accepted: job {}", resp.job_id),
|
||||
Err(e) => log::warn!("init failed: {e}"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Settings view: theme picker + daemon info card. Persists the
|
||||
//! chosen theme to disk via `paths::config_dir() / "sjnmusic-gui" /
|
||||
//! "theme.txt"`.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
use crate::state::Theme;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Settings");
|
||||
ui.separator();
|
||||
|
||||
ui.add_space(8.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Theme").strong());
|
||||
ui.horizontal(|ui| {
|
||||
let dark = ui.selectable_label(app.theme == Theme::Dark, "🌙 Dark");
|
||||
if dark.clicked() {
|
||||
app.theme = Theme::Dark;
|
||||
persist_theme(Theme::Dark);
|
||||
}
|
||||
let light = ui.selectable_label(app.theme == Theme::Light, "☀ Light");
|
||||
if light.clicked() {
|
||||
app.theme = Theme::Light;
|
||||
persist_theme(Theme::Light);
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Default is dark. Choice persists per-machine.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Daemon").strong());
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"The GUI is a remote control. It does not own audio.\n\
|
||||
Edit ~/.sjn/music/config.toml and restart the daemon to\n\
|
||||
change host/port, library paths, fuzzy threshold, …",
|
||||
)
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Keyboard").strong());
|
||||
ui.label(egui::RichText::new("Space → pause/resume").small());
|
||||
ui.label(egui::RichText::new("Esc → clear focus / cancel").small());
|
||||
});
|
||||
}
|
||||
|
||||
fn persist_theme(theme: Theme) {
|
||||
if let Some(path) = theme_path() {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&path, theme.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_path() -> Option<std::path::PathBuf> {
|
||||
let mut p = dirs::config_dir()?;
|
||||
p.push("sjnmusic-gui");
|
||||
p.push("theme.txt");
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Load the persisted theme if the file exists, otherwise None.
|
||||
pub fn load_persisted_theme() -> Option<String> {
|
||||
let path = theme_path()?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) if s.trim().eq_ignore_ascii_case("light") => Some("light".to_string()),
|
||||
Ok(_) => Some("dark".to_string()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Stats view: total plays + total time cards + top songs list.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::SJNMusicApp;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
||||
ui.heading("Stats");
|
||||
ui.separator();
|
||||
|
||||
if app.stats_data.is_none() && !app.stats_loading {
|
||||
match app.daemon.stats() {
|
||||
Ok(s) => app.stats_data = Some(s),
|
||||
Err(e) => {
|
||||
app.push_toast(format!("Stats failed: {}", e), crate::state::ToastLevel::Warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Refresh").clicked() {
|
||||
app.stats_loading = true;
|
||||
match app.daemon.stats() {
|
||||
Ok(s) => app.stats_data = Some(s),
|
||||
Err(e) => app.push_toast(format!("Refresh failed: {}", e), crate::state::ToastLevel::Warn),
|
||||
}
|
||||
app.stats_loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
let stats = match app.stats_data.clone() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("(no stats yet)").weak());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("stats-cards")
|
||||
.num_columns(2)
|
||||
.spacing([16.0, 16.0])
|
||||
.show(ui, |ui| {
|
||||
card(ui, "Total plays", &stats.total_plays.to_string());
|
||||
card(ui, "Total time", &crate::fmt::big_secs(stats.total_secs as f64));
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Top songs").strong());
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.max_height(400.0)
|
||||
.show(ui, |ui| {
|
||||
if stats.top_songs.is_empty() {
|
||||
ui.label(egui::RichText::new("No plays yet.").weak());
|
||||
return;
|
||||
}
|
||||
for (i, t) in stats.top_songs.iter().enumerate() { egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{}.", i + 1));
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(t.name.as_deref().unwrap_or("(missing)")).strong());
|
||||
ui.label(egui::RichText::new(format!("id {}", t.song_id)).small().weak());
|
||||
});
|
||||
ui.label(format!("{} plays", t.plays));
|
||||
ui.label(crate::fmt::mmss(t.total_secs as f64));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn card(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||
egui::Frame::group(ui.style())
|
||||
.fill(ui.visuals().faint_bg_color)
|
||||
.show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(label).small().weak());
|
||||
ui.label(egui::RichText::new(value).strong().size(28.0));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user