The island opens with nothing playing too: the last song played (kept across restarts) with Play, which starts its player, and chips that start players
This commit is contained in:
@@ -42,6 +42,15 @@ impl Service for Art {
|
||||
*Self::write() = Self { url, path };
|
||||
}
|
||||
|
||||
// the song playing, kept for the card shown while nothing plays
|
||||
let (url, path) = {
|
||||
let art = Self::read();
|
||||
|
||||
(art.url.clone(), art.path.clone())
|
||||
};
|
||||
|
||||
crate::last::watch(&url, &path);
|
||||
|
||||
// 4 checks a second: after next/previous the new cover follows the title quickly
|
||||
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
||||
}
|
||||
|
||||
@@ -329,6 +329,11 @@ settings! {
|
||||
row_letters: usize = 30, 4..=200; "Letters of a device name a menu row shows before cutting the middle"
|
||||
chip_letters: usize = 13, 4..=200; "Letters of a device name the output chip shows before cutting the middle"
|
||||
}
|
||||
idle {
|
||||
players: String = String::from("cider, spotify, tidal-hifi"); "Players the card offers to start while nothing plays: their desktop file names (cider for cider.desktop), comma-separated"
|
||||
wait_s: u64 = 30, 1..=300; "How long a player started from the card gets to start playing before the card gives up"
|
||||
check_ms: u64 = 500, 100..=5000; "How often a player started from the card is looked for and asked to play"
|
||||
}
|
||||
}
|
||||
|
||||
sound {
|
||||
|
||||
+26
-41
@@ -245,23 +245,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
let spectrum = live.then(Spectrum::read);
|
||||
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
||||
|
||||
let Some(player) = shown(&media, island.selected.as_deref()) else {
|
||||
/*
|
||||
* nothing playing: a quiet pill, so the island is always there. never
|
||||
* visible(false): hyprland 0.56 configures a layer surface before its first
|
||||
* commit, so a window that starts hidden gets a size anyway, amane logs "no
|
||||
* child to draw" and waits for frame callbacks an undrawn surface never gets
|
||||
*/
|
||||
return window.click_through().child(
|
||||
Rectangle::new()
|
||||
.width(Parent)
|
||||
.height(Parent)
|
||||
.padding(Padding { top: c::pill::top_gap(), right: 0.0, bottom: 0.0, left: 0.0 })
|
||||
.align_child(Center, Start)
|
||||
.translate(0.0, slide)
|
||||
.child(idle(&theme::now().surfaces())),
|
||||
);
|
||||
};
|
||||
// nothing playing: a quiet pill, which opens into the last song's card (last.rs)
|
||||
let player = shown(&media, island.selected.as_deref());
|
||||
|
||||
let (grow, pill, reveal) = phases(open);
|
||||
|
||||
@@ -280,7 +265,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
}
|
||||
|
||||
let width = mix(c::pill::width() + c::animation::hover_grow() * (lift + 2.0 * pop), c::card::width(), grow);
|
||||
let height = mix(c::pill::height(), wide_h(), grow);
|
||||
let height = mix(c::pill::height(), if player.is_some() { wide_h() } else { crate::last::height() }, grow);
|
||||
let radius = mix(c::pill::height() / 2.0, c::card::radius(), grow);
|
||||
|
||||
/*
|
||||
@@ -290,7 +275,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
*/
|
||||
let wallpaper = theme::now().surfaces();
|
||||
|
||||
let colors = if cover_colors {
|
||||
let colors = if cover_colors && player.is_some() {
|
||||
let cover = backdrop_colors(&Palette::read());
|
||||
|
||||
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
||||
@@ -299,22 +284,24 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
||||
};
|
||||
|
||||
// at most one face at a time, each faded by a veil instead of a gpu layer
|
||||
let face = if reveal > 0.0 {
|
||||
let scene = Scene {
|
||||
player,
|
||||
players: media.players(),
|
||||
island: &island,
|
||||
sound: &sound,
|
||||
art: &art.path,
|
||||
levels,
|
||||
};
|
||||
let face = match player {
|
||||
Some(player) if reveal > 0.0 => {
|
||||
let scene = Scene {
|
||||
player,
|
||||
players: media.players(),
|
||||
island: &island,
|
||||
sound: &sound,
|
||||
art: &art.path,
|
||||
levels,
|
||||
};
|
||||
|
||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
||||
} else if pill > 0.0 {
|
||||
veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors)
|
||||
} else {
|
||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
||||
}
|
||||
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors),
|
||||
None if reveal > 0.0 => veiled(crate::last::card(), width, height, radius, reveal, &colors),
|
||||
None if pill > 0.0 => veiled(idle(), width, height, radius, pill, &colors),
|
||||
// mid-grow: the empty shape alone, the cheapest frames of the animation
|
||||
Rectangle::new().width(0.0).height(0.0)
|
||||
_ => Rectangle::new().width(0.0).height(0.0),
|
||||
};
|
||||
|
||||
// the face centred along the top: while the shape grows it is narrower than the shape
|
||||
@@ -408,7 +395,7 @@ pub fn focused_monitor() -> Option<String> {
|
||||
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
|
||||
}
|
||||
|
||||
fn collapse() {
|
||||
pub fn collapse() {
|
||||
let mut island = Island::write();
|
||||
|
||||
island.expanded = false;
|
||||
@@ -589,17 +576,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||||
}
|
||||
|
||||
// the island while no player is open: a music note and "Nothing playing", on the pill's surface;
|
||||
// the words only where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim
|
||||
fn idle(colors: &[Color; 3]) -> Rectangle {
|
||||
// the pill while no player is open: a music note and "Nothing playing"; the words only
|
||||
// where they fit with half the pill's height to spare at each end, else the note alone, so it never nears the rim
|
||||
fn idle() -> Rectangle {
|
||||
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0);
|
||||
let words = Text::new("Nothing playing").size(text::body()).color(theme::now().dim);
|
||||
let fits = matches!(words.width(), amane::Size::Fixed(words_w) if note + 8.0 + words_w + h <= width);
|
||||
|
||||
let row = if fits { Row::new(children![icon(Glyph::Note, note, theme::now().accent), words]) } else { Row::new(children![icon(Glyph::Note, note, theme::now().accent)]) };
|
||||
let content = Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center));
|
||||
|
||||
surface::shell(width, h, h / 2.0, colors, 0.0, content)
|
||||
Rectangle::new().width(width).height(h).align_child(Center, Center).child(row.gap(8.0).align(Center))
|
||||
}
|
||||
|
||||
// the pill: cover, title, the player's live levels
|
||||
@@ -1328,7 +1313,7 @@ fn title(player: &MediaPlayer) -> &str {
|
||||
}
|
||||
|
||||
// a round cover with a pink rim, optionally with a cd hole; a soft gradient while there is none or it decodes
|
||||
fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
||||
pub fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
|
||||
let frame = Rectangle::new()
|
||||
.width(size)
|
||||
.height(size)
|
||||
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
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))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod edge;
|
||||
mod glyph;
|
||||
mod hypr;
|
||||
mod island;
|
||||
mod last;
|
||||
mod map;
|
||||
mod parse;
|
||||
mod prompt;
|
||||
|
||||
Reference in New Issue
Block a user