mpris and bar control impl
gitignore fix import error
This commit is contained in:
1 parent
73f21e20ce
commit
8a0dd7615e
42 files changed
+4026
-227
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user