315 lines
12 KiB
Rust
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))
|
|
}
|