mpris and bar control impl

gitignore

fix import error
This commit is contained in:
Samuel Onoja committed 2026-10-05 16:12:24 +01:00
1 parent 73f21e20ce
commit 8a0dd7615e
42 files changed
+4026 -227

No files matched your search

+6 -2
View File
@@ -225,7 +225,11 @@ impl FereseShell {
.and_then(|output| output.size)
.map_or(0., |(width, _)| width as f32)
- 2. * (shell_theme.panel_padding + shell_theme.bar_margin_horizontal as f32);
let title_width = (available - 920.).clamp(0., 360.);
let media_visible = self.media.snapshot.selected.is_some();
let title_width = (available - 920. - if media_visible { 400. } else { 0. }).clamp(0., 360.);
// Without a center title, give media controls more of a narrow bar.
// Keep equal sides when the title is shown so it stays centered.
let right_portion = if media_visible && title_width == 0. { 2 } else { 1 };
let title = if self.config.status.window_title && title_width > 0. {
focused_bar_title(&self.snapshot, focused_output)
} else {
@@ -270,7 +274,7 @@ impl FereseShell {
container(left).width(Length::Fill),
center,
container(right)
.width(Length::Fill)
.width(Length::FillPortion(right_portion))
.align_x(alignment::Horizontal::Right),
]
.spacing(8)
+59
View File
@@ -12,6 +12,7 @@ mod config;
mod control;
mod display_mode;
mod keybinding_guide;
mod media;
mod motion;
mod note_store;
mod notification_ui;
@@ -178,6 +179,7 @@ struct FereseShell {
status_service: status::Service,
status: status::Snapshot,
recorder: recording::Recorder,
media: media::Model,
calendar_offset: i32,
status_error: Option<status::ActionError>,
theme_error: Option<String>,
@@ -246,6 +248,14 @@ enum Message {
ClockChanged(clock::Labels),
RecorderEvent(recording::Event),
RecorderElapsed,
MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>),
MediaAction(serde_json::Value),
MediaCompleted(Result<(), String>),
MediaTick,
MediaChoose,
MediaSeek(u64),
MediaSeekCommit,
MediaArt(media::Artwork),
ControlReady,
ActivateWorkspace(u64),
ToggleOverview,
@@ -316,6 +326,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
recorder: recording::Recorder::default(),
media: media::Model::default(),
calendar_offset: 0,
status_error: None,
theme_error: None,
@@ -393,6 +404,9 @@ impl cosmic::Application for FereseShell {
}),
)
.map(Message::ClockChanged),
self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt),
self.recorder.subscription().map(Message::RecorderEvent),
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
self.status_service.subscription().map(Message::StatusUpdated),
@@ -427,6 +441,45 @@ impl cosmic::Application for FereseShell {
)
}
Message::MediaUpdated(snapshot) => {
self.media.receive(snapshot);
self.refresh_media_art(
self.menu
.as_ref()
.is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()),
);
Task::none()
}
Message::MediaAction(args) => self.media.command(args),
Message::MediaCompleted(result) => self.media.completed(result),
Message::MediaTick => {
self.media.now_us = ferese_ipc::media::now_us();
Task::none()
}
Message::MediaChoose => {
self.media.choosing = !self.media.choosing;
Task::none()
}
Message::MediaSeek(position) => {
self.media.seeking = Some(position);
Task::none()
}
Message::MediaSeekCommit => {
let Some(position) = self.media.seeking.take() else {
return Task::none();
};
let mut args = self.media.action("seek");
args["position_us"] = serde_json::json!(position);
args["track_id"] =
serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone()));
self.media.command(args)
}
Message::MediaArt(art) => {
if self.media.art.accepts(&art) {
self.media.artwork = Some(art);
}
Task::none()
}
Message::ThemeChanged(snapshot) => {
let mut config = self.config.clone();
config.apply_theme(&snapshot.presented);
@@ -787,6 +840,7 @@ impl cosmic::Application for FereseShell {
if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot;
let mut tasks = vec![reload_task];
let mut visibility_changed = false;
for entry in &mut self.outputs {
let output = self
@@ -802,6 +856,7 @@ impl cosmic::Application for FereseShell {
}
entry.hidden = hidden;
visibility_changed = true;
if let Some(effects) = &entry.effects
&& let Err(error) = effects.set_visible(!hidden)
{
@@ -815,6 +870,10 @@ impl cosmic::Application for FereseShell {
if self.bar_hidden(self.bar_surface_id) {
tasks.push(self.destroy_menu());
}
if visibility_changed {
self.refresh_media_art(false);
}
reload_task = Task::batch(tasks);
}
for command in poll.commands {
+199
View File
@@ -0,0 +1,199 @@
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::theme::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 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));
}
}
+334
View File
@@ -0,0 +1,334 @@
use std::collections::VecDeque;
use std::io::{Cursor, Read};
use std::os::unix::fs::OpenOptionsExt;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use base64::Engine;
use cosmic::iced::Subscription;
use cosmic::iced::futures::SinkExt;
use cosmic::widget::image;
const MAX_BYTES: usize = 2 * 1024 * 1024;
const CACHE_SIZE: usize = 16;
const SIDE: u32 = 128;
#[derive(Clone, Debug)]
pub(crate) struct Artwork {
key: Key,
pub handle: Option<image::Handle>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Key {
uri: String,
owner: String,
track_id: Option<String>,
title: String,
artist: String,
}
impl Key {
fn from_player(player: &ferese_ipc::media::Player) -> Option<Self> {
Some(Self {
uri: player.art_url.clone()?,
owner: player.owner.clone(),
track_id: player.track_id.clone(),
title: player.title.clone(),
artist: player.artist.clone(),
})
}
}
#[derive(Default)]
struct Queue {
pending: Option<Key>,
stopped: bool,
}
pub(crate) struct Service {
queue: Arc<(Mutex<Queue>, Condvar)>,
receive: Updates,
requested: Option<Key>,
}
impl Service {
pub fn new() -> Self {
let queue = Arc::new((Mutex::new(Queue::default()), Condvar::new()));
let (send, receive) = tokio::sync::watch::channel(None);
let worker_queue = queue.clone();
let _ = std::thread::Builder::new()
.name("ferese-media-art".into())
.spawn(move || {
let agent = ureq::AgentBuilder::new()
.redirects(0)
.timeout(Duration::from_secs(5))
.build();
work(worker_queue, send, |uri| load(&agent, uri).ok());
});
Self {
queue,
receive: Updates(Arc::new(receive)),
requested: None,
}
}
pub fn request(&mut self, player: Option<&ferese_ipc::media::Player>) {
let key = player.and_then(Key::from_player);
if self.requested == key {
return;
}
self.requested = key.clone();
self.queue.0.lock().unwrap().pending = key;
self.queue.1.notify_one();
}
pub fn accepts(&self, art: &Artwork) -> bool {
self.requested.as_ref() == Some(&art.key)
}
pub fn subscription(&self) -> Subscription<Artwork> {
Subscription::run_with(self.receive.clone(), stream)
}
}
impl Drop for Service {
fn drop(&mut self) {
self.queue.0.lock().unwrap().stopped = true;
self.queue.1.notify_one();
}
}
fn work(
queue: Arc<(Mutex<Queue>, Condvar)>,
send: tokio::sync::watch::Sender<Option<Artwork>>,
mut load: impl FnMut(&str) -> Option<image::Handle>,
) {
let mut cache: VecDeque<Artwork> = VecDeque::new();
loop {
let (lock, changed) = &*queue;
let mut state = lock.lock().unwrap();
while state.pending.is_none() && !state.stopped {
state = changed.wait(state).unwrap();
}
if state.stopped {
return;
}
let key = state.pending.take().unwrap();
drop(state);
let artwork = match cache.iter().position(|art| art.key == key) {
Some(index) => cache.remove(index).unwrap(),
None => Artwork {
handle: load(&key.uri),
key,
},
};
cache.push_front(artwork.clone());
cache.truncate(CACHE_SIZE);
if send.send(Some(artwork)).is_err() {
return;
}
}
}
// Receiver identity stays stable while the latest result is replaced.
#[derive(Clone)]
struct Updates(Arc<tokio::sync::watch::Receiver<Option<Artwork>>>);
impl std::hash::Hash for Updates {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::ptr::hash(Arc::as_ptr(&self.0), state);
}
}
fn stream(receive: &Updates) -> impl cosmic::iced::futures::Stream<Item = Artwork> + use<> {
let mut receive = receive.0.as_ref().clone();
cosmic::iced::stream::channel(1, async move |mut output| {
loop {
let art = receive.borrow_and_update().clone();
if let Some(art) = art
&& output.send(art).await.is_err()
{
return;
}
if receive.changed().await.is_err() {
return;
}
}
})
}
fn bounded(mut reader: impl Read) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
reader
.by_ref()
.take(MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > MAX_BYTES {
return Err("Album art exceeds the size limit".into());
}
Ok(bytes)
}
fn load(agent: &ureq::Agent, uri: &str) -> Result<image::Handle, String> {
let url = url::Url::parse(uri).map_err(|e| e.to_string())?;
let bytes = match url.scheme() {
"file" => {
let path = url.to_file_path().map_err(|_| "Invalid album art path")?;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.map_err(|e| e.to_string())?;
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("Album art is not a regular file".into());
}
bounded(file)?
}
"http" | "https" => {
let response = agent.get(uri).call().map_err(|e| e.to_string())?;
if response.status() != 200 {
return Err("Album art request failed".into());
}
bounded(response.into_reader())?
}
"data" => {
let (metadata, data) = uri.split_once(',').ok_or("Invalid album art data")?;
if !metadata.starts_with("data:image/")
|| !metadata.ends_with(";base64")
|| data.len() > MAX_BYTES * 4 / 3 + 4
{
return Err("Invalid album art data".into());
}
base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| e.to_string())?
}
_ => return Err("Unsupported album art URL".into()),
};
decode(bytes)
}
fn decode(bytes: Vec<u8>) -> Result<image::Handle, String> {
let mut reader = image_codec::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = image_codec::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(16 * 1024 * 1024);
reader.limits(limits);
let image = reader
.decode()
.map_err(|e| e.to_string())?
.thumbnail(SIDE, SIDE)
.to_rgba8();
Ok(image::Handle::from_rgba(
image.width(),
image.height(),
image.into_raw(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn key(uri: &str) -> Key {
Key::from_player(&ferese_ipc::media::Player {
art_url: Some(uri.into()),
..Default::default()
})
.unwrap()
}
#[test]
fn artwork_is_invalidated_when_the_track_changes_or_has_no_art() {
let mut model = crate::media::Model::default();
let player = ferese_ipc::media::Player {
art_url: Some("file:///nonexistent/ferese-media-test.png".into()),
track_id: Some("/track/one".into()),
..Default::default()
};
let art = Artwork {
key: Key::from_player(&player).unwrap(),
handle: Some(image::Handle::from_rgba(1, 1, vec![255; 4])),
};
model.snapshot = Arc::new(ferese_ipc::media::Snapshot {
selected: Some(player.clone()),
..Default::default()
});
model.refresh_art(true);
assert!(model.art.accepts(&art));
model.artwork = Some(art.clone());
let mut next = player;
next.track_id = Some("/track/two".into());
model.snapshot = Arc::new(ferese_ipc::media::Snapshot {
selected: Some(next.clone()),
..Default::default()
});
model.refresh_art(true);
assert!(!model.art.accepts(&art));
assert!(model.artwork.is_none());
model.artwork = Some(art);
next.art_url = None;
model.snapshot = Arc::new(ferese_ipc::media::Snapshot {
selected: Some(next),
..Default::default()
});
model.refresh_art(true);
assert!(model.artwork.is_none());
}
#[test]
fn one_decoder_coalesces_replacements_while_an_image_is_loading() {
let queue = Arc::new((
Mutex::new(Queue {
pending: Some(key("first")),
stopped: false,
}),
Condvar::new(),
));
let (send, _receive) = tokio::sync::watch::channel(None);
let (started, starts) = std::sync::mpsc::channel();
let (resume, resumes) = std::sync::mpsc::channel();
let worker_queue = queue.clone();
let worker = std::thread::spawn(move || {
work(worker_queue, send, |uri| {
started.send(uri.to_owned()).unwrap();
if uri == "first" {
resumes.recv().unwrap();
}
None
})
});
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "first");
queue.0.lock().unwrap().pending = Some(key("second"));
queue.0.lock().unwrap().pending = Some(key("latest"));
resume.send(()).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "latest");
queue.0.lock().unwrap().stopped = true;
queue.1.notify_one();
worker.join().unwrap();
assert!(starts.try_recv().is_err());
}
#[test]
fn encoded_and_decoded_art_have_resource_limits() {
assert!(bounded(Cursor::new(vec![0; MAX_BYTES + 1])).is_err());
let mut bytes = Cursor::new(Vec::new());
image_codec::DynamicImage::new_rgba8(5000, 1)
.write_to(&mut bytes, image_codec::ImageFormat::Png)
.unwrap();
assert!(decode(bytes.into_inner()).is_err());
assert!(decode(vec![0; 40]).is_err());
}
#[test]
fn unsupported_urls_do_not_fetch() {
assert!(load(&ureq::Agent::new(), "ftp://example.test/art.png").is_err());
assert!(load(&ureq::Agent::new(), "data:text/plain;base64,dGVzdA==").is_err());
}
}
@@ -0,0 +1,146 @@
use std::sync::Arc;
use std::time::Duration;
use cosmic::iced::Subscription;
use cosmic::iced::futures::SinkExt;
use ferese_ipc::media::Snapshot;
use serde_json::json;
pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
Subscription::run(stream)
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
cosmic::iced::stream::channel(1, async |mut output| {
loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(value) = connection.call("media-get", json!({})) else {
return;
};
let Ok(mut snapshot) = serde_json::from_value::<Snapshot>(value) else {
return;
};
loop {
let revision = snapshot.revision;
if send.send(Some(Arc::new(snapshot))).is_err() {
return;
}
match connection
.wait("media-watch", json!({"since":revision}))
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
{
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot
&& output.send(snapshot).await.is_err()
{
return;
}
}
drop(cancellation);
if output.send(Arc::new(Snapshot::default())).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::futures::StreamExt;
use ferese_ipc::{Request, Response, read_frame, write_frame};
use std::os::unix::net::UnixListener;
#[test]
fn reconnect_clears_ghost_media_and_drop_cancels_a_watch() {
const CHILD: &str = "FERESE_MEDIA_SUBSCRIPTION_TEST";
if std::env::var_os(CHILD).is_none() {
let root = tempfile::tempdir().unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"media::subscription::tests::reconnect_clears_ghost_media_and_drop_cancels_a_watch",
"--nocapture",
])
.env(CHILD, "1")
.env("XDG_RUNTIME_DIR", root.path())
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let path = ferese_ipc::theme::socket_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let listener = UnixListener::bind(path).unwrap();
let (watch_send, watch_receive) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
for revision in [1, 2] {
let (mut connection, _) = listener.accept().unwrap();
connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap();
let get: Request = read_frame(&mut connection).unwrap();
assert_eq!(get.command, "media-get");
let snapshot = Snapshot {
revision,
..Default::default()
};
write_frame(
&mut connection,
&Response::success(get.id, serde_json::to_value(snapshot).unwrap()),
)
.unwrap();
let watch: Request = read_frame(&mut connection).unwrap();
assert_eq!(watch.command, "media-watch");
assert_eq!(watch.args["since"], revision);
assert_ne!(get.id, watch.id);
if revision == 2 {
watch_send.send(()).unwrap();
use std::io::Read;
assert_eq!(connection.read(&mut [0]).unwrap(), 0);
}
}
});
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
let mut subscription = Box::pin(stream());
for revision in [1, 0, 2] {
let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next())
.await
.unwrap()
.unwrap();
assert_eq!(snapshot.revision, revision);
}
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap())
.await
.unwrap();
drop(subscription);
});
server.join().unwrap();
}
}
+36 -3
View File
@@ -133,12 +133,45 @@ pub(crate) fn button<'a, M: Clone + 'a>(
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
};
painted_button(button, paint)
}
pub(crate) fn circular_button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
fill: Option<cosmic::iced::Color>,
opacity: f32,
diameter: f32,
) -> Element<'a, M> {
let paint = move |progress: f32, pressed: bool| {
let mut style =
ferese_theme::controls::shell_button(foreground, false, opacity, diameter / 2.0, progress, pressed);
style.shape = Some(cosmic::iced::border::Shape::Circular);
if let Some(fill) = fill {
style.background = Some(
cosmic::iced::Color {
a: opacity * if pressed { 0.75 } else { 1.0 - 0.1 * progress },
..fill
}
.into(),
);
}
style
};
painted_button(button, paint)
}
fn painted_button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let content = button
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
+7 -1
View File
@@ -12,7 +12,11 @@ impl FereseShell {
let theme = self.config.theme.for_bar();
let islands = self.config.status.bar_layout == ferese_config::BarLayout::Islands;
let metrics = BarMetrics::from(theme);
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
let mut controls = row::with_capacity(7).spacing(1).align_y(Alignment::Center);
if self.media.snapshot.selected.is_some() {
controls = controls.push(super::media::bar(self, theme, metrics));
}
for kind in [
Menu::System,
Menu::Network,
@@ -157,6 +161,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
},
Menu::Calendar => "Calendar".into(),
Menu::Recording => "Screen recording".into(),
Menu::Media => "Now playing".into(),
Menu::Battery => s.battery.as_ref().map_or_else(
|| "Battery: unavailable".into(),
|b| format!("Battery: {}%, {}", b.percent, b.status),
@@ -203,6 +208,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
}),
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
Menu::Recording => (ferese_theme::icons::RECORD, true),
Menu::Media => (ferese_theme::icons::MEDIA, true),
Menu::Battery => {
let Some(b) = &s.battery else {
return (ferese_theme::icons::BATTERY_EMPTY, false);
@@ -25,6 +25,11 @@ impl FereseShell {
if let Some(menu) = &mut self.menu
&& menu.motion.closing()
{
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
}
menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
@@ -32,6 +37,16 @@ impl FereseShell {
return self.close_menu();
}
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
self.media.error = None;
}
if kind != Menu::Media {
self.refresh_media_art(false);
}
if kind == Menu::Calendar {
self.calendar_offset = 0;
}
@@ -122,6 +137,7 @@ impl FereseShell {
let Some(menu) = self.menu.take() else {
return Task::none();
};
self.refresh_media_art(false);
if menu.kind == Menu::Notifications {
self.notifications.history_open = false;
self.notifications.hovered = None;
@@ -133,6 +149,7 @@ impl FereseShell {
}
pub fn close_menu(&mut self) -> Task<Message> {
self.refresh_media_art(false);
let Some(menu) = &mut self.menu else {
return Task::none();
};
+471
View File
@@ -0,0 +1,471 @@
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Alignment, Border, Length, Rectangle, mouse};
use cosmic::widget::{button, column, container, image, mouse_area, row, scrollable, slider};
use cosmic::{Element, theme};
use ferese_ipc::media::Playback;
use ferese_theme::icons;
use serde_json::json;
use super::{Menu, MenuRows, MenuStyle};
use crate::{
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion, text,
};
const SEEK_THUMB_DIAMETER: f32 = 10.0;
fn seek_slider_style(style: MenuStyle) -> cosmic::theme::iced::Slider {
let paint = std::rc::Rc::new(move |_: &cosmic::Theme| {
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
let accent = color_with_opacity(style.theme.accent, style.opacity);
Style {
rail: Rail {
backgrounds: (accent.into(), style.muted.scale_alpha(0.10).into()),
width: 4.0,
border: cosmic::iced::Border {
shape: BorderShape::Circular,
radius: 2.0.into(),
..Default::default()
},
},
handle: Handle {
corner_shape: BorderShape::Circular,
shape: HandleShape::Circle {
radius: SEEK_THUMB_DIAMETER / 2.0,
},
background: accent.into(),
border_width: 0.0,
border_color: cosmic::iced::Color::TRANSPARENT,
},
breakpoint: Breakpoint {
color: cosmic::iced::Color::TRANSPARENT,
},
}
});
cosmic::theme::iced::Slider::Custom {
active: paint.clone(),
hovered: paint.clone(),
dragging: paint,
}
}
fn seek_pill<'a>(
content: impl Into<Element<'a, cosmic::Action<Message>>>,
style: MenuStyle,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.width(Length::Fill)
.padding([2, 0])
.class(theme::Container::custom(move |_| container::Style {
background: Some(color_with_opacity(style.theme.border, style.opacity).into()),
border: cosmic::iced::Border {
shape: BorderShape::Circular,
radius: 12.0.into(),
..Default::default()
},
..Default::default()
}))
.into()
}
pub(super) fn bar<'a>(
shell: &'a FereseShell,
palette: ShellTheme,
metrics: BarMetrics,
) -> Element<'a, cosmic::Action<Message>> {
let Some(player) = &shell.media.snapshot.selected else {
return text("").into();
};
let selected = shell.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Media);
let foreground = color(if selected { palette.accent } else { palette.text_primary });
let height = (metrics.group_item_height - 4.0).max(0.0);
let side = height.min(24.0);
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(side)
.height(side)
.content_fit(cosmic::iced::ContentFit::Cover)
.border_radius(motion::radius(6.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(
icons::MEDIA,
side as u16,
foreground,
color(palette.accent),
))
.center_x(side)
.center_y(side)
.width(side)
.into(),
};
let label = text(player.label())
.size(metrics.text_size)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.width(Length::Shrink)
.height(height)
.align_y(cosmic::iced::alignment::Vertical::Center)
.class(theme::Text::Color(foreground));
let label = container(label).max_width(112);
let details = button::custom(bar_content(
row![cover, label].spacing(8).align_y(Alignment::Center),
height,
))
.name(format!("Now playing: {}", player.label()))
.padding([0, 4])
.height(height)
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu(
Menu::Media,
Rectangle {
x: (bounds.x - offset.x).round() as i32,
y: (bounds.y - offset.y).round() as i32,
width: bounds.width.round() as i32,
height: bounds.height.round() as i32,
},
))
});
let playing = player.status == Playback::Playing;
let mut toggle = button::custom(bar_content(
accented_icon(
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
metrics.icon_size,
foreground,
color(palette.accent),
),
height,
))
.name(if playing { "Pause" } else { "Play" })
.height(height)
.padding([0, 6]);
if player.can_toggle() {
toggle = toggle.on_press(cosmic::Action::App(Message::MediaAction(
shell.media.action("play-pause"),
)));
}
let mut bar_style = crate::bar::bar_group_style(palette);
bar_style.border.width(0.0);
let controls = container(
row![
motion::button(details, foreground, false, 1.0),
motion::button(toggle, foreground, false, 1.0)
]
.spacing(2)
.align_y(Alignment::Center),
)
.padding(2)
.height(metrics.group_item_height)
.class(theme::Container::custom(move |_| bar_style));
let mut area = mouse_area(controls);
if player.can_raise {
area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(shell.media.action("raise"))));
}
if player.can_control && player.volume.is_some() {
let action = shell.media.action("volume");
area = area.on_scroll(move |delta| {
let mut action = action.clone();
action["delta"] = json!(volume_delta(delta));
cosmic::Action::App(Message::MediaAction(action))
});
}
area.into()
}
fn volume_delta(delta: mouse::ScrollDelta) -> f64 {
let delta = match delta {
mouse::ScrollDelta::Lines { y, .. } => f64::from(y) * 0.05,
mouse::ScrollDelta::Pixels { y, .. } => f64::from(y) * 0.001,
};
if delta.is_finite() { delta.clamp(-1.0, 1.0) } else { 0.0 }
}
fn transport<'a>(
shell: &'a FereseShell,
source: &'static [u8],
label: &'static str,
action: &str,
enabled: bool,
style: MenuStyle,
) -> Element<'a, cosmic::Action<Message>> {
let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
let foreground = match fill {
Some(fill) => cosmic::iced::Color {
a: fill.a,
..ferese_theme::foreground(fill, color(style.theme.surface_popover))
},
None => {
if enabled {
style.primary
} else {
style.muted
}
}
};
let mut control = button::custom(
container(accented_icon(source, 24, foreground, color(style.theme.accent)))
.center_x(24)
.center_y(24),
)
.padding(10)
.width(44)
.height(44)
.name(label);
if enabled {
control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action))));
}
motion::circular_button(control, foreground, fill, style.opacity, 44.0)
}
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
if let Some(player) = &shell.media.snapshot.selected {
let cover: Element<'_, cosmic::Action<Message>> =
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
Some(handle) => image(handle.clone())
.width(64)
.height(64)
.content_fit(cosmic::iced::ContentFit::Contain)
.border_radius(motion::radius(12.0))
.shape(BorderShape::Continuous)
.into(),
None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent)))
.center_x(64)
.center_y(64)
.into(),
};
let mut info = column![text(player.label()).size(15).width(Length::Fill).ellipsize(
cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2))
)]
.spacing(4)
.width(Length::Fill);
if !player.artist.is_empty() {
info = info.push(
text(&player.artist)
.size(12)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.class(theme::Text::Color(style.muted)),
);
}
if !player.album.is_empty() {
info = info.push(
text(&player.album)
.size(11)
.width(Length::Fill)
.ellipsize(cosmic::iced::widget::text::Ellipsize::End(
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
))
.class(theme::Text::Color(style.muted)),
);
}
rows = rows.push(row![cover, info].spacing(12).align_y(Alignment::Center));
let mut playback = column::with_capacity(2).spacing(6).width(Length::Fill);
if let (Some(length), Some(position)) = (
player.length_us.filter(|length| *length > 0),
player.position_at(shell.media.now_us),
) {
let position = shell.media.seeking.unwrap_or(position).min(length);
let mut progress = column::with_capacity(2).spacing(2);
if player.can_control && player.can_seek && player.track_id.is_some() {
let value = (position as f64 / length as f64 * 10_000.0).round() as u32;
progress = progress.push(seek_pill(
container(
slider(0..=10_000_u32, value, move |value| {
cosmic::Action::App(Message::MediaSeek(seek_position(length, value)))
})
.on_release(cosmic::Action::App(Message::MediaSeekCommit))
.handle_width(SEEK_THUMB_DIAMETER)
.height(20)
.class(seek_slider_style(style))
.width(Length::Fill),
)
// Extend the native slider's layout by its reserved thumb
// radius so its rail and pointer mapping reach both edges.
.padding(cosmic::iced::Padding {
left: -SEEK_THUMB_DIAMETER / 2.0,
right: -SEEK_THUMB_DIAMETER / 2.0,
..Default::default()
}),
style,
));
} else {
progress = progress.push(seek_pill(
container(
cosmic::iced::widget::ProgressBar::new(0.0..=1.0, (position as f64 / length as f64) as f32)
.girth(4)
.length(Length::Fill)
.class(cosmic::theme::iced::ProgressBar::custom(move |_| {
cosmic::iced::widget::progress_bar::Style {
background: style.muted.scale_alpha(0.35).into(),
bar: color_with_opacity(style.theme.accent, style.opacity).into(),
border: cosmic::iced::Border {
shape: BorderShape::Circular,
radius: 2.0.into(),
..Default::default()
},
}
})),
)
.center_y(20),
style,
));
}
progress = progress.push(
row![
text(time_label(position))
.font(cosmic::iced::Font::MONOSPACE)
.size(11)
.class(theme::Text::Color(style.muted))
.width(Length::Fill),
text(time_label(length))
.font(cosmic::iced::Font::MONOSPACE)
.size(11)
.class(theme::Text::Color(style.muted))
]
.spacing(12),
);
playback = playback.push(progress);
}
let playing = player.status == Playback::Playing;
playback = playback.push(
container(
row![
transport(
shell,
icons::MEDIA_PREVIOUS,
"Previous track",
"previous",
player.can_control && player.can_previous,
style
),
transport(
shell,
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
if playing { "Pause" } else { "Play" },
"play-pause",
player.can_toggle(),
style
),
transport(
shell,
icons::MEDIA_NEXT,
"Next track",
"next",
player.can_control && player.can_next,
style
),
]
.spacing(12),
)
.center_x(Length::Fill),
);
rows = rows.push(playback);
let label = if shell.media.snapshot.pinned.is_some() {
format!("{} · pinned", player.identity)
} else {
player.identity.clone()
};
rows = rows.push(motion::button(
button::custom(text(label).size(12))
.padding([6, 8])
.on_press(cosmic::Action::App(Message::MediaChoose)),
style.primary,
false,
style.opacity,
));
} else {
rows = rows.push(text("No active player").size(13).class(theme::Text::Color(style.muted)));
}
if shell.media.choosing || shell.media.snapshot.selected.is_none() {
let mut choices = column::with_capacity(shell.media.snapshot.players.len() + 1).spacing(4);
choices = choices.push(super::controls::menu_button(
"Choose automatically",
Message::MediaAction(json!({"action":"auto"})),
style.primary,
style.opacity,
));
for player in &shell.media.snapshot.players {
let args = json!({"player":player.name,"owner":player.owner});
let mut pin = args.clone();
pin["action"] = json!("pin");
let mut ignore = args;
ignore["action"] = json!("ignore");
ignore["ignored"] = json!(!player.ignored);
let label = if player.identity.is_empty() {
&player.name
} else {
&player.identity
};
choices = choices.push(
row![
super::controls::menu_button(
label,
Message::MediaAction(pin),
if player.ignored { style.muted } else { style.primary },
style.opacity
),
super::controls::menu_button(
if player.ignored { "Show" } else { "Ignore" },
Message::MediaAction(ignore),
style.muted,
style.opacity
),
]
.spacing(6),
);
}
rows = rows.push(scrollable(choices).height(Length::Fixed(140.0)));
}
if let Some(error) = &shell.media.error {
rows = rows.push(text(error).size(12).class(theme::Text::Color(style.muted)));
}
rows
}
fn seek_position(length: u64, value: u32) -> u64 {
(u128::from(length) * u128::from(value.min(10_000)) / 10_000) as u64
}
fn time_label(us: u64) -> String {
let seconds = us / 1_000_000;
format!("{}:{:02}", seconds / 60, seconds % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seeking_handles_short_tracks_and_large_durations() {
assert_eq!(seek_position(500_000, 5000), 250_000);
assert_eq!(seek_position(500_000, 10_000), 500_000);
assert_eq!(seek_position(u64::MAX, 10_000), u64::MAX);
assert_eq!(seek_position(1, 100_000), 1);
}
#[test]
fn wheel_changes_are_bounded_and_pixel_scrolling_is_not_a_full_step() {
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }), 0.05);
assert_eq!(volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }), 0.001);
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: f32::NAN }), 0.0);
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }), 1.0);
}
}
+6 -2
View File
@@ -6,6 +6,7 @@ mod bluetooth;
mod calendar;
mod controls;
mod lifecycle;
mod media;
mod network;
mod system;
@@ -37,6 +38,7 @@ pub enum Menu {
Battery,
Calendar,
Recording,
Media,
Notifications,
System,
}
@@ -60,7 +62,7 @@ impl Menu {
Self::System => 360.0,
Self::Battery => 328.0,
Self::Calendar => 268.0,
Self::Notifications => 368.0,
Self::Notifications | Self::Media => 368.0,
_ => 300.0,
}
}
@@ -78,6 +80,7 @@ impl Menu {
Self::Battery => "Battery",
Self::Calendar => "Calendar",
Self::Recording => "Screen recording",
Self::Media => "Now playing",
Self::Notifications => "Notifications",
}
}
@@ -88,7 +91,7 @@ impl Menu {
Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(),
Self::Battery => status.battery.is_some(),
Self::Calendar | Self::Recording => true,
Self::Calendar | Self::Recording | Self::Media => true,
Self::Notifications => status.notifications.is_some(),
Self::System => true,
}
@@ -216,6 +219,7 @@ impl FereseShell {
Menu::Calendar => rows.push(calendar::view(self.calendar_offset, theme, p)),
Menu::Notifications => notification_controls(self, rows, style, false),
Menu::Recording => rows,
Menu::Media => media::view(self, rows, style),
};
if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push(text("Service unavailable").size(13).class(theme::Text::Color(muted)));
+2
View File
@@ -12,6 +12,7 @@ impl FereseShell {
if matches!(event, wayland::OutputEvent::Removed) {
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
let entry = self.outputs.remove(index);
self.refresh_media_art(false);
let menu = if self.bar_surface_id == entry.bar {
self.destroy_menu()
} else {
@@ -101,6 +102,7 @@ impl FereseShell {
size,
hidden,
});
self.refresh_media_art(false);
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(),
move |_| SctkLayerSurfaceSettings {