Files
feresaria/shell/ferese-shell/src/media.rs
T
Samuel OnojaandSamuel Onoja 6b0d903ad0 Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-12 22:52:25 +01:00

207 lines
6.7 KiB
Rust

mod art;
mod subscription;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use cosmic::app::Task;
use cosmic::iced::Subscription;
use ferese_ipc::media::{Playback, Snapshot, now_us};
use serde_json::{Value, json};
use crate::{FereseShell, Message};
pub(crate) use art::Artwork;
pub(crate) struct Model {
pub snapshot: Arc<Snapshot>,
pub error: Option<String>,
pub choosing: bool,
pub seeking: Option<u64>,
pub now_us: u64,
pub art: art::Service,
pub artwork: Option<Artwork>,
busy: bool,
pending: VecDeque<Value>,
}
impl Default for Model {
fn default() -> Self {
Self {
snapshot: Arc::new(Snapshot::default()),
error: None,
choosing: false,
seeking: None,
now_us: now_us(),
art: art::Service::new(),
artwork: None,
busy: false,
pending: VecDeque::new(),
}
}
}
impl Model {
pub fn action(&self, action: &str) -> Value {
match &self.snapshot.selected {
Some(player) => json!({"action": action, "player":player.name, "owner":player.owner}),
None => json!({"action": action}),
}
}
pub fn subscription(&self) -> Subscription<Arc<Snapshot>> {
subscription::subscription()
}
pub fn tick_subscription(&self, visible: bool, on_battery: bool) -> Subscription<()> {
if visible && needs_progress(&self.snapshot) {
cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 })).map(|_| ())
} else {
Subscription::none()
}
}
pub fn receive(&mut self, snapshot: Arc<Snapshot>) {
let previous = self.snapshot.selected.as_ref();
let next = snapshot.selected.as_ref();
if previous.map(|p| (&p.owner, &p.track_id)) != next.map(|p| (&p.owner, &p.track_id)) {
self.seeking = None;
self.error = None;
}
self.snapshot = snapshot;
self.now_us = now_us();
}
pub fn refresh_art(&mut self, visible: bool) {
let player = if visible { self.snapshot.selected.as_ref() } else { None };
self.art.request(player);
if visible && self.artwork.as_ref().is_some_and(|art| !self.art.accepts(art)) {
self.artwork = None;
}
}
pub fn command(&mut self, args: Value) -> Task<Message> {
if self.busy {
// Coalesce adjacent wheel events without dropping transport clicks.
if args["action"] == "volume"
&& let Some(pending) = self.pending.back_mut()
&& pending["action"] == "volume"
&& pending["owner"] == args["owner"]
{
pending["delta"] = json!(
(pending["delta"].as_f64().unwrap_or(0.0) + args["delta"].as_f64().unwrap_or(0.0)).clamp(-1.0, 1.0)
);
} else if self.pending.len() < 8 {
self.pending.push_back(args);
} else {
self.error = Some("Media controls are busy".into());
}
return Task::none();
}
self.busy = true;
self.error = None;
cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || {
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
connection.call("media-action", args).map(|_| ())
})
.await
.unwrap_or_else(|error| Err(error.to_string()));
cosmic::Action::App(Message::MediaCompleted(result))
})
}
pub fn completed(&mut self, result: Result<(), String>) -> Task<Message> {
self.busy = false;
self.error = result.err();
match self.pending.pop_front() {
Some(args) => self.command(args),
None => Task::none(),
}
}
}
fn needs_progress(snapshot: &Snapshot) -> bool {
snapshot.selected.as_ref().is_some_and(|player| {
player.status == Playback::Playing && player.position_us.is_some() && player.length_us.is_some()
})
}
impl FereseShell {
pub fn refresh_media_art(&mut self, menu_visible: bool) {
self.media
.refresh_art(menu_visible || self.outputs.iter().any(|output| !output.hidden));
}
pub fn media_tick_subscription(&self) -> Subscription<()> {
let hub_visible = self.outputs.iter().any(|entry| {
!entry.hidden
&& entry.runtime.hub.as_ref().is_some_and(|hub| {
hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
})
});
let visible = (hub_visible
|| self
.menu
.as_ref()
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()))
&& self.outputs.iter().any(|output| !output.hidden);
let on_battery = self
.status
.battery
.as_ref()
.is_some_and(|battery| battery.status == "Discharging");
self.media.tick_subscription(visible, on_battery)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_updates_coalesce_and_transport_commands_remain_ordered_and_bounded() {
let mut model = Model {
busy: true,
..Default::default()
};
for _ in 0..100 {
let _ = model.command(json!({"action":"volume", "owner":":1.1", "delta":0.01}));
}
assert_eq!(model.pending.len(), 1);
assert!((model.pending[0]["delta"].as_f64().unwrap() - 1.0).abs() < 1e-9);
let _ = model.command(json!({"action":"next"}));
let _ = model.command(json!({"action":"previous"}));
assert_eq!(model.pending[1]["action"], "next");
assert_eq!(model.pending[2]["action"], "previous");
for _ in 0..100 {
let _ = model.command(json!({"action":"next"}));
}
assert_eq!(model.pending.len(), 8);
assert!(model.error.is_some());
}
#[test]
fn progress_only_runs_for_playing_media_with_known_position_and_duration() {
let mut snapshot = Snapshot::default();
assert!(!needs_progress(&snapshot));
snapshot.selected = Some(ferese_ipc::media::Player {
status: Playback::Playing,
position_us: Some(0),
length_us: Some(100),
..Default::default()
});
assert!(needs_progress(&snapshot));
snapshot.selected.as_mut().unwrap().status = Playback::Paused;
assert!(!needs_progress(&snapshot));
snapshot.selected.as_mut().unwrap().status = Playback::Playing;
snapshot.selected.as_mut().unwrap().length_us = None;
assert!(!needs_progress(&snapshot));
}
}