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:
server2
2026-10-19 11:55:50 +02:00
parent 756f38be4e
commit 689b53be0f
6 changed files with 352 additions and 40 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing". It opens too, into the last song played (kept across restarts) with Play, which starts its player and asks it to play, and chips that start the players of `island.idle.players` (desktop file names, `gtk-launch`).
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
- **Output switcher:** moves only the shown player's audio to another device.
@@ -238,6 +238,7 @@ bind = SUPER, M, exec, amane ipc call island toggle
- `~/.config/amane/config.toml`: every setting (see [Settings](#settings)); rewritten by the config window.
- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell.
- `$XDG_STATE_HOME/amane/` (`~/.local/state/amane/`): `last-played` (the song the island last heard, `key = value`) and a copy of its cover, for the card shown while nothing plays.
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
+9
View File
@@ -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()));
}
+5
View File
@@ -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 {
+12 -30
View File
@@ -249,23 +249,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);
@@ -284,7 +269,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);
/*
@@ -294,7 +279,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))
@@ -303,7 +288,8 @@ 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 face = match player {
Some(player) if reveal > 0.0 => {
let scene = Scene {
player,
players: media.players(),
@@ -314,11 +300,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
};
veiled(card(&scene), width, height, radius, reveal, &colors)
} else if pill > 0.0 {
veiled(compact(player, playing(player, &island), &art.path, levels, c::pill::width()), width, height, radius, pill, &colors)
} else {
}
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels, c::pill::width()), 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_face(c::pill::width()), 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
@@ -670,11 +657,6 @@ 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
fn idle(colors: &[Color; 3]) -> Rectangle {
surface::shell(c::pill::width(), c::pill::height(), c::pill::height() / 2.0, colors, 0.0, idle_face(c::pill::width()))
}
/*
* a note and "Nothing playing", centred. the words only where they fit with half the
* pill's height to spare at each end, else the note alone: an elided text takes the whole
@@ -1427,7 +1409,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
View File
@@ -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))
}
+1
View File
@@ -7,6 +7,7 @@ mod glyph;
mod home;
mod hypr;
mod island;
mod last;
mod map;
mod notices;
mod parse;