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:
@@ -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.
|
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.
|
- **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.
|
- **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.
|
- **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/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.
|
- `~/.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>`.
|
- `~/.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.
|
- `$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`).
|
- 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`).
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ impl Service for Art {
|
|||||||
*Self::write() = Self { url, path };
|
*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
|
// 4 checks a second: after next/previous the new cover follows the title quickly
|
||||||
thread::sleep(Duration::from_millis(timing::cover_check_ms()));
|
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"
|
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"
|
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 {
|
sound {
|
||||||
|
|||||||
+25
-40
@@ -245,23 +245,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
|
|||||||
let spectrum = live.then(Spectrum::read);
|
let spectrum = live.then(Spectrum::read);
|
||||||
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
|
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, which opens into the last song's card (last.rs)
|
||||||
/*
|
let player = shown(&media, island.selected.as_deref());
|
||||||
* 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())),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let (grow, pill, reveal) = phases(open);
|
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 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);
|
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 wallpaper = theme::now().surfaces();
|
||||||
|
|
||||||
let colors = if cover_colors {
|
let colors = if cover_colors && player.is_some() {
|
||||||
let cover = backdrop_colors(&Palette::read());
|
let cover = backdrop_colors(&Palette::read());
|
||||||
|
|
||||||
std::array::from_fn(|stop| Color::blend(wallpaper[stop], cover[stop], reveal))
|
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
|
// 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 {
|
||||||
let scene = Scene {
|
Some(player) if reveal > 0.0 => {
|
||||||
player,
|
let scene = Scene {
|
||||||
players: media.players(),
|
player,
|
||||||
island: &island,
|
players: media.players(),
|
||||||
sound: &sound,
|
island: &island,
|
||||||
art: &art.path,
|
sound: &sound,
|
||||||
levels,
|
art: &art.path,
|
||||||
};
|
levels,
|
||||||
|
};
|
||||||
|
|
||||||
veiled(card(&scene), width, height, radius, reveal, &colors)
|
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)
|
Some(player) if pill > 0.0 => veiled(compact(player, playing(player, &island), &art.path, levels), width, height, radius, pill, &colors),
|
||||||
} else {
|
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
|
// 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
|
// the face centred along the top: while the shape grows it is narrower than the shape
|
||||||
@@ -600,17 +587,15 @@ pub fn faded(color: Color, alpha: u8) -> Color {
|
|||||||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
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 pill while no player is open: a music note and "Nothing playing"; the words only
|
||||||
// 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
|
// 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 {
|
fn idle() -> Rectangle {
|
||||||
let (width, h, note) = (c::pill::width(), c::pill::height(), 15.0);
|
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 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 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 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));
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the pill: cover, title, the player's live levels
|
// the pill: cover, title, the player's live levels
|
||||||
@@ -1339,7 +1324,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
|
// 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()
|
let frame = Rectangle::new()
|
||||||
.width(size)
|
.width(size)
|
||||||
.height(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))
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod glyph;
|
|||||||
mod home;
|
mod home;
|
||||||
mod hypr;
|
mod hypr;
|
||||||
mod island;
|
mod island;
|
||||||
|
mod last;
|
||||||
mod map;
|
mod map;
|
||||||
mod notices;
|
mod notices;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
|||||||
Reference in New Issue
Block a user