Files
amane-config/src/last.rs
T

315 lines
12 KiB
Rust

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<Song>,
// 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<String>,
// what a start is doing, or what went wrong
note: Option<String>,
}
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<String>) {
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<String>) {
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<String>, 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<Box<dyn Widget>> = 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<dyn Widget>, 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<Box<dyn Widget>> = 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<dyn Widget>, Box::new(side)]).gap(card::side_gap())) as Box<dyn Widget>, Box::new(bottom)]).gap(gap))
}