use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; use std::{env, fs}; use amane::{Bus, Center, Column, Full, Media, Pointer, Rectangle, Row, Service, Start, Text, Weight, Widget}; use crate::config::appearance::{font, overlays, text}; use crate::config::island::{card, controls, disc, idle}; use crate::glyph::{Glyph, icon}; use crate::island::{self, Island}; use crate::{parse, theme}; /* * the island's card while nothing plays: the song it last heard (kept in * $XDG_STATE_HOME/amane across restarts, its cover copied beside it) with a button that * starts its player and asks it to play, and chips that start the players of * island.idle.players. art.rs's thread calls watch() with every check it makes, so this * needs no thread of its own */ #[derive(Clone, Default, PartialEq)] struct Song { // the player's mpris bus name and the name it goes by player: String, identity: String, title: String, artist: String, // the cover's copy, empty without one cover: String, } pub struct Last { song: Option, // the players to start, as (desktop file name, the name it shows), and the setting they were read for apps: Vec<(String, String)>, apps_for: Option, // what a start is doing, or what went wrong note: Option, } impl Service for Last { fn new() -> Self { let saved = parse::settings(&fs::read_to_string(folder().join("last-played")).unwrap_or_default()); let get = |key: &str| saved.iter().find(|(name, _)| name == key).map_or(String::new(), |(_, value)| value.clone()); let song = Song { player: get("player"), identity: get("identity"), title: get("title"), artist: get("artist"), cover: get("cover") }; Self { song: Some(song).filter(|song| !song.title.is_empty()), apps: Vec::new(), apps_for: None, note: None } } // changes through watch() and the buttons fn listen() {} } const PREFIX: &str = "org.mpris.MediaPlayer2."; fn folder() -> PathBuf { let state = env::var_os("XDG_STATE_HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(env::var_os("HOME").unwrap_or_default()).join(".local/state")); state.join("amane") } // one line, for the key = value file fn clean(words: &str) -> String { words.replace(['\n', '\r'], " ").trim().to_string() } /* * art.rs's check, with the cover it holds for the island's player: notes the song the * island's player plays, and reads the players to start again when their setting changed */ pub fn watch(url: &str, path: &str) { let wanted = idle::players(); if Last::read().apps_for.as_deref() != Some(wanted.as_str()) { let apps = desktop_apps(&wanted); let mut last = Last::write(); last.apps = apps; last.apps_for = Some(wanted); } let selected = Island::read().selected.clone(); let now = { let media = Media::read(); island::shown(&media, selected.as_deref()).filter(|player| player.playing() && !player.title().is_empty()).map(|player| { let song = Song { player: player.name().to_string(), identity: clean(player.identity()), title: clean(player.title()), artist: clean(player.artist()), cover: String::new() }; (song, player.art_url() == url && !path.is_empty()) }) }; let Some((mut song, cover_ready)) = now else { return }; let known = Last::read().song.clone(); let same = known.as_ref().is_some_and(|known| known.player == song.player && known.title == song.title && known.artist == song.artist); let has_cover = known.as_ref().is_some_and(|known| !known.cover.is_empty()); // the same song, and its cover kept already or not there yet if same && (has_cover || !cover_ready) { return; } song.cover = if cover_ready { keep_cover(path, &song) } else { String::new() }; if let Some(old) = known.filter(|old| !old.cover.is_empty() && old.cover != song.cover) { let _ = fs::remove_file(&old.cover); } save(&song); Last::write().song = Some(song); } // a copy of the cover under a name of its own (amane keeps a picture by its path, so a reused name would show the old one) fn keep_cover(path: &str, song: &Song) -> String { let mut hasher = DefaultHasher::new(); (&song.player, &song.title, &song.artist).hash(&mut hasher); let file = folder().join(format!("cover-{:016x}", hasher.finish())); let _ = fs::create_dir_all(folder()); if fs::copy(path, &file).is_ok() { file.to_string_lossy().into_owned() } else { String::new() } } fn save(song: &Song) { let _ = fs::create_dir_all(folder()); let text = format!( "# the song the island last heard, for its card while nothing plays\nplayer = {}\nidentity = {}\ntitle = {}\nartist = {}\ncover = {}\n", song.player, song.identity, song.title, song.artist, song.cover ); let _ = fs::write(folder().join("last-played"), text); } // the players of the setting that have a desktop file, with the name it gives them fn desktop_apps(wanted: &str) -> Vec<(String, String)> { let home = PathBuf::from(env::var_os("HOME").unwrap_or_default()); let folders = [home.join(".local/share/applications"), PathBuf::from("/usr/local/share/applications"), PathBuf::from("/usr/share/applications")]; wanted .split(',') .map(str::trim) .filter(|id| !id.is_empty()) .filter_map(|id| { let entry = folders.iter().find_map(|folder| fs::read_to_string(folder.join(format!("{id}.desktop"))).ok())?; let name = parse::settings(&entry).into_iter().find(|(key, _)| key == "Name").map_or_else(|| id.to_string(), |(_, name)| name); Some((id.to_string(), name)) }) .collect() } // a player's desktop file name from its bus name: org.mpris.MediaPlayer2.firefox.instance_1_2 is firefox fn app_of(bus: &str) -> String { bus.strip_prefix(PREFIX).unwrap_or(bus).split('.').next().unwrap_or_default().to_string() } fn say(note: Option) { Last::write().note = note; } /* * starts a player (gtk-launch), and with `resume` (its bus name) asks it to play until it * does or island.idle.wait_s runs out. the card closes: the pill shows the song once it plays */ fn start(id: String, resume: Option) { let name = Last::read().apps.iter().find(|(app, _)| *app == id).map_or_else(|| id.clone(), |(_, name)| name.clone()); island::collapse(); thread::spawn(move || { say(Some(format!("Starting {name}…"))); let started = Command::new("gtk-launch").arg(&id).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()); if !started { say(Some(format!("{name} couldn't be started (gtk-launch {id})"))); return; } let Some(bus) = resume else { say(None); return; }; let until = Instant::now() + Duration::from_secs(idle::wait_s()); while Instant::now() < until { // an instance number may follow the name it had before let found = { let media = Media::read(); media.players().iter().find(|player| player.name() == bus || player.name().starts_with(&format!("{bus}."))).map(|player| (player.name().to_string(), player.playing())) }; match found { Some((_, true)) => { say(None); return; } // Play is harmless when it already plays, so it's asked until it does Some((player, false)) => { Bus::session().call(&player, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", "Play", &[]); } None => {} } thread::sleep(Duration::from_millis(idle::check_ms())); } say(Some(format!("{name} opened but didn't start playing"))); }); } pub fn height() -> f32 { 2.0 * card::pad() + disc::size() + card::gap() + controls::chip_height() } // a line of text in a box as wide as the side, cut with … when longer fn line(words: impl Into, size: f32, color: amane::Color, bold: bool, width: f32) -> Rectangle { let words = Text::new(words).size(size).color(color).weight(if bold { Weight::Bold } else { Weight::Regular }).elide(); Rectangle::new().width(width).height(size + 8.0).align_child(Start, Center).child(words) } fn chip(words: &str, pressed: impl Fn() + 'static) -> Rectangle { let width = words.chars().count() as f32 * text::small() * font::letter_width() + 18.0; Rectangle::new() .width(width) .height(controls::chip_height()) .radius(Full) .fill(overlays::chip()) .align_child(Center, Center) .cursor(Pointer) .on_click(move |_| pressed()) .child(Text::new(words).size(text::small()).color(theme::now().text)) } pub fn card() -> Rectangle { let last = Last::read(); let theme = theme::now(); let (width, pad, gap) = (card::width(), card::pad(), card::gap()); let inner_w = width - 2.0 * pad; let side_w = inner_w - disc::size() - card::side_gap(); // the last song's cover on the cd, or the empty cd let cover = last.song.as_ref().map_or("", |song| song.cover.as_str()); let cd = Rectangle::new().width(disc::size()).height(disc::size()).align_child(Center, Center).child(island::cover(cover, disc::cover(), 2.0, true)); let mut lines: Vec> = Vec::new(); match &last.song { Some(song) => { let (app, bus) = (app_of(&song.player), song.player.clone()); let words = format!("Play in {}", song.identity); let button_w = words.chars().count() as f32 * text::small() * font::letter_width() + 44.0; lines.push(Box::new(line("Last played", text::small(), theme.dim, false, side_w))); lines.push(Box::new(line(song.title.as_str(), text::title(), theme.text, true, side_w))); lines.push(Box::new(line(song.artist.as_str(), text::body(), theme.dim, false, side_w))); lines.push(Box::new( Rectangle::new() .width(button_w) .height(controls::chip_height() + 10.0) .radius(Full) .fill(theme.accent) .align_child(Center, Center) .cursor(Pointer) .on_click(move |_| start(app.clone(), Some(bus.clone()))) .child(Row::new(vec![Box::new(icon(Glyph::Play, 12.0, theme.on_accent)) as Box, Box::new(Text::new(words).size(text::small()).weight(Weight::Bold).color(theme.on_accent))]).gap(8.0).align(Center)), )); } None => { lines.push(Box::new(line("Nothing playing", text::title(), theme.text, true, side_w))); lines.push(Box::new(line("Start a player below", text::small(), theme.dim, false, side_w))); } } if let Some(note) = &last.note { lines.push(Box::new(line(note.as_str(), text::small(), theme.dim, false, side_w))); } let side = Rectangle::new().width(side_w).height(disc::size()).align_child(Start, Center).child(Column::new(lines).gap(gap / 2.0)); // the players to start let mut chips: Vec> = vec![Box::new(Text::new("Start").size(text::small()).color(theme.dim))]; for (id, name) in &last.apps { let id = id.clone(); chips.push(Box::new(chip(name, move || start(id.clone(), None)))); } if last.apps.is_empty() { chips.push(Box::new(Text::new("no player of island.idle.players has a desktop file").size(text::small()).color(theme.dim))); } let bottom = Rectangle::new().width(inner_w).height(controls::chip_height()).align_child(Start, Center).child(Row::new(chips).gap(gap / 2.0).align(Center)); Rectangle::new().width(width).height(height()).padding(pad).child(Column::new(vec![Box::new(Row::new(vec![Box::new(cd) as Box, Box::new(side)]).gap(card::side_gap())) as Box, Box::new(bottom)]).gap(gap)) }