use crate::db::Db;
use crate::recap::{self, YearData};
use crate::ui::Ui;
use adw::prelude::*;
use gtk::glib;
use gtk::{gio, pango};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
pub fn present(ui: &Rc<Ui>, years: Vec<i32>) {
if years.is_empty() {
ui.core.toast("No listening history yet for a Year in Music");
return;
}
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(20)
.margin_top(20)
.margin_bottom(28)
.margin_start(28)
.margin_end(28)
.build();
let year_labels: Vec<String> = years.iter().map(|y| y.to_string()).collect();
let year_refs: Vec<&str> = year_labels.iter().map(String::as_str).collect();
let dropdown = gtk::DropDown::from_strings(&year_refs);
dropdown.set_halign(gtk::Align::Start);
let summary = gtk::Box::new(gtk::Orientation::Vertical, 16);
content.append(&dropdown);
content.append(&summary);
let export_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(10)
.halign(gtk::Align::Center)
.margin_top(6)
.build();
let export_story = gtk::Button::with_label("Export Story PNG");
export_story.add_css_class("pill");
export_story.add_css_class("suggested-action");
let export_post = gtk::Button::with_label("Export Post PNG");
export_post.add_css_class("pill");
export_row.append(&export_story);
export_row.append(&export_post);
content.append(&export_row);
let data: Rc<RefCell<Option<Rc<YearData>>>> = Rc::new(RefCell::new(None));
let load = {
let ui = Rc::clone(ui);
let summary = summary.clone();
let data = Rc::clone(&data);
let years = years.clone();
Rc::new(move |index: usize| {
let Some(year) = years.get(index).copied() else { return };
let db_path = ui.core.db_path.clone();
let durations: HashMap<String, i64> = ui
.core
.library
.borrow()
.tracks
.iter()
.filter(|t| t.duration > 0.0)
.map(|t| (recap::track_key(&t.title, &t.artist), t.duration.round() as i64))
.collect();
let (tx, rx) = async_channel::bounded::<YearData>(1);
std::thread::Builder::new()
.name("flaccy-yim".into())
.spawn(move || {
let rows = Db::open(&db_path)
.map(|db| db.fetch_all_scrobble_rows())
.unwrap_or_default();
let _ = tx.send_blocking(recap::compute_year(&rows, year, &durations));
})
.ok();
let summary = summary.clone();
let data = Rc::clone(&data);
glib::spawn_future_local(async move {
let Ok(computed) = rx.recv().await else { return };
let computed = Rc::new(computed);
*data.borrow_mut() = Some(Rc::clone(&computed));
render_summary(&summary, &computed);
});
})
};
load(0);
{
let load = Rc::clone(&load);
dropdown.connect_selected_notify(move |dropdown| {
load(dropdown.selected() as usize);
});
}
{
let ui = Rc::clone(ui);
let data = Rc::clone(&data);
export_story.connect_clicked(move |_| export(&ui, &data, true));
}
{
let ui = Rc::clone(ui);
let data = Rc::clone(&data);
export_post.connect_clicked(move |_| export(&ui, &data, false));
}
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.propagate_natural_height(true)
.child(&content)
.build();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(
&adw::HeaderBar::builder()
.title_widget(&adw::WindowTitle::new("Year in Music", ""))
.build(),
);
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.content_width(560)
.content_height(720)
.child(&toolbar)
.build();
dialog.present(Some(&ui.window));
}
fn render_summary(summary: >k::Box, data: &YearData) {
while let Some(child) = summary.first_child() {
summary.remove(&child);
}
let hero = gtk::Label::builder()
.label(format!("{}", data.year))
.xalign(0.0)
.build();
hero.add_css_class("title-1");
summary.append(&hero);
let tiles = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.homogeneous(true)
.build();
tiles.append(&tile(&data.total_plays.to_string(), "PLAYS"));
tiles.append(&tile(&data.total_minutes.to_string(), "MINUTES"));
tiles.append(&tile(&data.longest_streak.to_string(), "DAY STREAK"));
tiles.append(&tile(data.persona, "PERSONA"));
summary.append(&tiles);
let distinct = gtk::Label::builder()
.label(format!(
"{} artists · {} albums · {} tracks",
data.distinct_artists, data.distinct_albums, data.distinct_tracks
))
.xalign(0.0)
.build();
distinct.add_css_class("dim");
summary.append(&distinct);
let mut facts: Vec<String> = Vec::new();
if let Some(day) = data.peak_day {
facts.push(format!(
"Biggest day: {} with {} plays",
day.format("%B %-d"),
data.peak_day_plays
));
}
if let Some(hour) = data.peak_hour {
facts.push(format!("Peak listening hour: {hour:02}:00"));
}
for fact in facts {
let label = gtk::Label::builder().label(&fact).xalign(0.0).build();
label.add_css_class("dim");
summary.append(&label);
}
summary.append(&top_section("TOP ARTISTS", &data.top_artists));
let albums: Vec<(String, i64)> = data
.top_albums
.iter()
.map(|(album, artist, count)| (format!("{album} — {artist}"), *count))
.collect();
summary.append(&top_section("TOP ALBUMS", &albums));
let tracks: Vec<(String, i64)> = data
.top_tracks
.iter()
.map(|(title, artist, count)| (format!("{title} — {artist}"), *count))
.collect();
summary.append(&top_section("TOP TRACKS", &tracks));
}
fn tile(value: &str, caption: &str) -> gtk::Widget {
let tile = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.build();
tile.add_css_class("stat-tile");
let value_label = gtk::Label::builder()
.label(value)
.xalign(0.0)
.ellipsize(pango::EllipsizeMode::End)
.build();
value_label.add_css_class("yim-value");
tile.append(&value_label);
let caption_label = gtk::Label::builder().label(caption).xalign(0.0).build();
caption_label.add_css_class("stat-caption");
tile.append(&caption_label);
tile.upcast()
}
fn top_section(title: &str, rows: &[(String, i64)]) -> gtk::Widget {
let section = gtk::Box::new(gtk::Orientation::Vertical, 6);
let heading = gtk::Label::builder().label(title).xalign(0.0).build();
heading.add_css_class("stat-caption");
section.append(&heading);
for (index, (name, count)) in rows.iter().enumerate() {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
let rank = gtk::Label::builder()
.label(format!("{}", index + 1))
.width_chars(2)
.xalign(1.0)
.build();
rank.add_css_class("top-list-rank");
row.append(&rank);
let name_label = gtk::Label::builder()
.label(name)
.xalign(0.0)
.hexpand(true)
.ellipsize(pango::EllipsizeMode::End)
.tooltip_text(name)
.build();
row.append(&name_label);
let count_label = gtk::Label::new(Some(&count.to_string()));
count_label.add_css_class("duration-label");
row.append(&count_label);
section.append(&row);
}
section.upcast()
}
fn export(ui: &Rc<Ui>, data: &Rc<RefCell<Option<Rc<YearData>>>>, story: bool) {
let Some(data) = data.borrow().clone() else { return };
let kind = if story { "story" } else { "post" };
let suggested = format!("flaccy-year-in-music-{}-{}.png", data.year, kind);
let (tx, rx) = async_channel::bounded::<Option<Vec<u8>>>(1);
let owned = (*data).clone();
std::thread::Builder::new()
.name("flaccy-card".into())
.spawn(move || {
let bytes =
crate::render::year_in_music_card(&owned, story).and_then(|card| card.png_bytes());
let _ = tx.send_blocking(bytes);
})
.ok();
let ui = Rc::clone(ui);
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Some(bytes)) => save_card(&ui, bytes, &suggested),
_ => ui.core.toast("Couldn't render the card"),
}
});
}
pub fn save_card(ui: &Rc<Ui>, png: Vec<u8>, suggested_name: &str) {
let dialog = gtk::FileDialog::builder()
.title("Export PNG")
.initial_name(suggested_name)
.build();
if let Some(pictures) = dirs::picture_dir().or_else(dirs::home_dir) {
dialog.set_initial_folder(Some(&gio::File::for_path(pictures)));
}
let window = ui.window.clone();
let ui = Rc::clone(ui);
dialog.save(Some(&window), None::<&gio::Cancellable>, move |result| {
let Ok(file) = result else { return };
let Some(path) = file.path() else { return };
match std::fs::write(&path, &png).map_err(|e| format!("{e}")) {
Ok(()) => {
crate::logger::info("ui", &format!("card exported to {}", path.display()));
ui.core.toast(&format!("Saved {}", path.display()));
}
Err(err) => {
crate::logger::error("ui", &format!("card export failed: {err}"));
ui.core.toast("Export failed");
}
}
});
}