mod imp { use crate::model::Song; use crate::{mpris, mpv}; use adw::prelude::*; use adw::subclass::prelude::*; use glib::subclass::InitializingObject; use glib::JoinHandle; use gtk::{gdk, gio, glib}; use std::cell::{Cell, RefCell}; use std::rc::Rc; use tracing::{event, span, Level}; use zbus::object_server::InterfaceRef; #[derive(gtk::CompositeTemplate, glib::Properties)] #[template(resource = "/eu/callcc/audrey/window.ui")] #[properties(wrapper_type = super::Window)] pub struct Window { #[template_child] pub(super) playbar: TemplateChild, #[template_child] pub(super) play_queue: TemplateChild, #[property(get, set, default = false)] can_click_shuffle_all: Cell, #[property(get, set, nullable)] playing_cover_art: RefCell>, #[property(type = Option, get = Self::song, nullable)] _song: (), pub(super) setup: crate::ui::Setup, pub(super) api: RefCell>>, pub(super) mpv: mpv::Handle, #[property(get)] playlist_model: gio::ListStore, #[property(type = i64, get = Self::volume, set = Self::set_volume, minimum = 0, maximum = 100)] _volume: (), #[property(type = bool, get = Self::mute, set = Self::set_mute)] _mute: (), #[property(type = bool, get = Self::pause, set = Self::set_pause)] _pause: (), #[property(type = i64, get = Self::playlist_pos)] _playlist_pos: (), #[property(type = f64, get = Self::time_pos)] _time_pos: (), #[property(type = f64, get = Self::duration)] _duration: (), // as reported by mpv, compare with song.duration #[property(type = bool, get = Self::idle_active)] _idle_active: (), #[property(type = i64, get = Self::playlist_count)] _playlist_count: (), pub(super) queued_seek: Cell>, pub(crate) initial_setup_handle: RefCell>>, mpv_event_loop_handle: RefCell>>, // really !, not () zbus_executor_loop_handle: RefCell>>, // same loading_cover_handle: RefCell>>, buffering_timeout: Cell>, time_pos_notify_timeout: Cell>, mpris_player: RefCell>>, } impl Default for Window { fn default() -> Self { let mpv = mpv::Handle::new(); mpv.set_property("audio-client-name", "audrey").unwrap(); mpv.set_property("user-agent", crate::USER_AGENT).unwrap(); mpv.set_property("vid", false).unwrap(); mpv.set_property("prefetch-playlist", true).unwrap(); mpv.observe_property(0, "volume").unwrap(); mpv.observe_property(1, "mute").unwrap(); mpv.observe_property(2, "pause").unwrap(); mpv.observe_property(3, "playlist-pos").unwrap(); mpv.observe_property(4, "idle-active").unwrap(); mpv.observe_property(6, "playlist-count").unwrap(); mpv.observe_property(7, "duration").unwrap(); mpv.observe_property(8, "path").unwrap(); // "Useful to drain property changes before a new file is loaded." mpv.add_hook(0, "on_before_start_file", 0).unwrap(); Self { playbar: Default::default(), play_queue: Default::default(), can_click_shuffle_all: Cell::new(false), playing_cover_art: Default::default(), _song: (), setup: Default::default(), api: Default::default(), mpv, playlist_model: gio::ListStore::new::(), _volume: (), _mute: (), _pause: (), _playlist_pos: (), _time_pos: (), _duration: (), _idle_active: (), _playlist_count: (), queued_seek: Default::default(), initial_setup_handle: Default::default(), mpv_event_loop_handle: Default::default(), zbus_executor_loop_handle: Default::default(), loading_cover_handle: Default::default(), buffering_timeout: Default::default(), time_pos_notify_timeout: Default::default(), mpris_player: Default::default(), } } } #[glib::object_subclass] impl ObjectSubclass for Window { const NAME: &'static str = "AudreyUiWindow"; type Type = super::Window; type ParentType = adw::ApplicationWindow; fn class_init(klass: &mut Self::Class) { klass.bind_template(); klass.bind_template_callbacks(); } fn instance_init(obj: &InitializingObject) { obj.init_template(); } } #[glib::derived_properties] impl ObjectImpl for Window { fn constructed(&self) { self.parent_constructed(); // update time-pos every 100 ms let window = self.obj().downgrade(); self.time_pos_notify_timeout .replace(Some(glib::timeout_add_local( std::time::Duration::from_millis(100), move || match window.upgrade() { None => glib::ControlFlow::Break, Some(window) => { if !window.idle_active() && !window.pause() { window.notify("time-pos"); } glib::ControlFlow::Continue } }, ))); let window = self.obj().downgrade(); let mpv_event_loop_handle = glib::spawn_future_local(async move { loop { let window = window.upgrade().unwrap(); let listener = window.imp().mpv.wakeup_listener(); // only send property change notifications after the event queue is drained let freeze_notify = window.freeze_notify(); while let Some(event) = window.imp().mpv.wait_event(0.0) { use crate::mpv::Event; match event { Event::PropertyChange(event) => window.imp().on_property_change(event), Event::StartFile(_) => window.imp().on_start_file(), Event::Hook(event) => window.imp().on_hook(event), Event::LogMessage(event) => window.imp().on_log_message(event), Event::Seek => window.imp().on_seek(), Event::PlaybackRestart => window.imp().on_playback_restart(), Event::EndFile(event) => window.imp().on_end_file(event), Event::Unknown(_) => { // either deprecated or future, ignore } Event::AudioReconfig => { // "This is relatively uninteresting, because there is no such thing as audio output embedding." // ^ ignore } _ => event!(Level::DEBUG, "unhandled {event:?}"), } } // send property change notifications now drop(freeze_notify); drop(window); listener.await; } }); self.mpv_event_loop_handle .replace(Some(mpv_event_loop_handle)); // set up mpris let window = self.obj().clone(); let zbus_executor_loop_handle = glib::spawn_future_local(async move { let conn = zbus::connection::Builder::session() .expect("could not connect to the session bus") .internal_executor(false) .build() .await .expect("could not build connection to the session bus"); futures::join!( glib::clone!( #[strong] conn, async move { crate::Mpris::setup(conn.object_server(), &window) .await .expect("could not serve mpris"); window.imp().mpris_player.replace(Some( mpris::Player::new(conn.object_server(), &window) .await .expect("could not serve mpris player"), )); // always set up handlers before requesting service name conn.request_name("org.mpris.MediaPlayer2.audrey") .await .expect("could not register name in session bus"); } ), async { loop { conn.executor().tick().await; } } ); }); self.zbus_executor_loop_handle .replace(Some(zbus_executor_loop_handle)); } } impl WidgetImpl for Window {} impl WindowImpl for Window {} impl ApplicationWindowImpl for Window {} impl AdwApplicationWindowImpl for Window {} #[gtk::template_callbacks] impl Window { #[template_callback] fn show_playbar_cover_art(&self, stack_child: Option<&str>) -> bool { stack_child != Some("play-queue") } #[template_callback] async fn shuffle_all(&self) { self.obj().set_can_click_shuffle_all(false); self.mpv.command(["stop"]).unwrap(); self.playlist_model.remove_all(); self.set_pause(false); let api = { let api = self.api.borrow(); Rc::clone(api.as_ref().unwrap()) }; for song in api.get_random_songs(10).await.unwrap().into_iter() { let song = Song::from_child(&api, &song, true); self.mpv .command(["loadfile", &song.stream_url(), "append-play"]) .unwrap(); self.playlist_model.append(&song); } self.obj().set_can_click_shuffle_all(true); } #[template_callback] fn show_setup_dialog(&self) { self.setup.present(Some(self.obj().as_ref())); } fn volume(&self) -> i64 { self.mpv .get_property::("volume") .unwrap() .try_into() .unwrap() } fn set_volume(&self, volume: i64) { self.mpv.set_property("volume", volume as i64).unwrap(); } fn mute(&self) -> bool { self.mpv.get_property("mute").unwrap() } fn set_mute(&self, mute: bool) { self.mpv.set_property("mute", mute).unwrap(); } fn pause(&self) -> bool { self.mpv.get_property("pause").unwrap() } fn set_pause(&self, pause: bool) { self.mpv.set_property("pause", pause).unwrap(); } fn playlist_pos(&self) -> i64 { self.mpv .get_property("playlist-pos") .unwrap() } fn time_pos(&self) -> f64 { if let Some(queued_seek) = self.queued_seek.get() { // counterfeit time-pos while the seek is ongoing queued_seek } else { match self.mpv.get_property("time-pos") { Ok(time_pos) => Ok(time_pos), Err(err) if err.is_property_unavailable() => Ok(0.0), //placeholder Err(err) => Err(err), } .unwrap() } } fn duration(&self) -> f64 { let duration = match self.mpv.get_property::("duration") { Ok(duration) => Ok(Some(duration)), Err(err) if err.is_property_unavailable() => { Ok(self.song().as_ref().map(|song| song.duration() as f64)) } Err(err) => Err(err), } .unwrap(); { let left = duration.map(|f| f as i64); let right = self.song().as_ref().map(crate::model::Song::duration); if left != right { event!( Level::WARN, "mpv duration {left:?} doesn not match subsonic duration {right:?}" ); } } duration.unwrap_or(0.0) // placeholder } fn idle_active(&self) -> bool { self.mpv.get_property("idle-active").unwrap() } fn playlist_count(&self) -> i64 { self.mpv .get_property::("playlist-count") .unwrap() .try_into() .unwrap() } fn song(&self) -> Option { match self.obj().playlist_pos().try_into() { Ok(playlist_pos) => Some( self.obj() .playlist_model() .item(playlist_pos) .unwrap() .dynamic_cast() .unwrap(), ), Err(_) => None, } } fn buffering_start(&self) { let started_buffering = std::time::Instant::now(); let window = self.obj().downgrade(); self.buffering_timeout .replace(Some(glib::timeout_add_local( std::time::Duration::from_millis(100), move || { match window.upgrade() { None => glib::ControlFlow::Break, Some(window) => { // 3 second period from gnome hig if started_buffering.elapsed() > std::time::Duration::from_secs(3) { window.imp().playbar.set_show_pulse_bar(true); window.imp().playbar.pulse_bar().pulse(); } glib::ControlFlow::Continue } } }, ))) .map(|source| source.remove()); } fn buffering_end(&self) { self.buffering_timeout.take().map(|source| source.remove()); self.playbar.set_show_pulse_bar(false); } fn on_property_change(&self, event: crate::mpv::event::PropertyEvent) { match event.reply_userdata { 0 => { assert_eq!(event.name, "volume"); self.obj().notify("volume"); } 1 => { assert_eq!(event.name, "mute"); self.obj().notify("mute"); } 2 => { assert_eq!(event.name, "pause"); self.obj().notify("pause"); } 3 => { assert_eq!(event.name, "playlist-pos"); self.obj().notify("playlist-pos"); } 4 => { assert_eq!(event.name, "idle-active"); self.obj().notify("idle-active"); } 6 => { assert_eq!(event.name, "playlist-count"); self.obj().notify("playlist-count"); } 7 => { assert_eq!(event.name, "duration"); self.obj().notify("duration"); } 8 => { assert_eq!(event.name, "path"); // sanity check match self.mpv.get_property::("path") { Ok(path) => { assert_eq!(path, self.obj().song().unwrap().stream_url()) } Err(err) if err.is_property_unavailable() => {} Err(err) => Err(err).unwrap(), } } _ => unreachable!(), } } fn on_start_file(&self) { event!(Level::INFO, "start file event"); self.obj().notify("song"); self.buffering_start(); let window = self.obj().clone(); let song_id = window.song().unwrap().id(); self.loading_cover_handle .replace(Some(glib::spawn_future_local(async move { let api = window.imp().api.borrow().as_ref().unwrap().clone(); let bytes = api .cover_art(&song_id, None) // full size .await .expect("could not load cover art for song {song_id}"); match window.song() { Some(song) if song.id() == song_id => { let texture = gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) .expect("could not create texture from cover art for {song_id}"); window.set_playing_cover_art(Some(texture)); } _ => { event!( Level::WARN, "was too late to fetch cover for song {song_id}" ) } } }))) .map(|handle| handle.abort()); // make sure this is reported as 0 self.obj().notify("time-pos"); } fn on_hook(&self, event: crate::mpv::event::HookEvent) { match event.reply_userdata { 0 => { assert_eq!(&event.name, "on_before_start_file"); event!(Level::DEBUG, "on_before_start_file triggered"); // just use this as a barrier self.mpv.continue_hook(event.id).unwrap(); } _ => unreachable!(), } } fn on_log_message(&self, event: crate::mpv::event::LogMessageEvent) { let span = span!(Level::DEBUG, "mpv_log", prefix = event.prefix); let _guard = span.enter(); match event.log_level { // level has to be 'static so this sux l if l <= 20 => { event!(target: "mpv_event", Level::ERROR, "{}", event.text.trim()) } l if l <= 30 => { event!(target: "mpv_event", Level::WARN, "{}", event.text.trim()) } l if l <= 40 => { event!(target: "mpv_event", Level::INFO, "{}", event.text.trim()) } l if l <= 60 => { event!(target: "mpv_event", Level::DEBUG, "{}", event.text.trim()) } l if l <= 70 => { event!(target: "mpv_event", Level::TRACE, "{}", event.text.trim()) } // should be unused _ => event!( target: "mpv_erroneus_event", Level::DEBUG, log_level = event.log_level, "{}", event.text.trim(), ), }; } fn on_seek(&self) { event!(Level::INFO, "seek event"); self.buffering_start(); } fn on_playback_restart(&self) { event!(Level::INFO, "playback restart event"); self.buffering_end(); self.obj().notify("time-pos"); if let Some(queued_seek) = self.queued_seek.take() { // a seek was tried before and failed, try again now event!(Level::INFO, "performing queued seek to {queued_seek}"); self.obj().seek(queued_seek); } } fn on_end_file(&self, event: crate::mpv::event::EndFileEvent) { event!(Level::INFO, "end file event: {event:?}"); self.obj().notify("song"); self.buffering_end(); if let Err(err) = event.reason { event!(Level::ERROR, "end file error: {err}"); } // cancel queued seek always self.queued_seek.set(None); self.obj().set_playing_cover_art(None::); // make sure the seekbar looks full self.obj().notify("time-pos"); } } impl Drop for Window { fn drop(&mut self) { event!(Level::DEBUG, "dropping AudreyUiWindow"); self.mpv_event_loop_handle.take().unwrap().abort(); self.zbus_executor_loop_handle.take().unwrap().abort(); self.time_pos_notify_timeout .take() .map(|source| source.remove()); self.buffering_timeout.take().map(|source| source.remove()); } } } use adw::prelude::*; use adw::subclass::prelude::*; use gtk::{gio, glib}; use std::rc::Rc; use tracing::{event, Level}; glib::wrapper! { pub struct Window(ObjectSubclass) @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; } impl Window { pub fn new(app: &impl IsA) -> Self { let window: Self = glib::Object::builder().property("application", app).build(); window.imp().setup.set_window(&window); let initial_setup_handle = { let setup = window.imp().setup.clone(); glib::spawn_future_local(async move { setup.load().await }) }; window .imp() .initial_setup_handle .replace(Some(initial_setup_handle)); window } pub fn playbin(&self) -> ! { todo!() } pub fn playlist_play_index(&self, index: Option) { match index.as_ref() { Some(index) => self .imp() .mpv .command(["playlist-play-index", &index.to_string()]) .unwrap(), None => self .imp() .mpv .command(["playlist-play-index", "none"]) .unwrap(), }; } pub fn playlist_remove(&self, index: i64) { self.imp() .mpv .command(["playlist-remove", &index.to_string()]) .unwrap(); self.playlist_model().remove(index as u32); } pub fn setup_connected(&self, api: crate::subsonic::Client) { self.imp().api.replace(Some(Rc::new(api))); self.set_can_click_shuffle_all(true); } pub fn playlist_next(&self) { self.imp().mpv.command(["playlist-next"]).unwrap(); } pub fn playlist_prev(&self) { self.imp().mpv.command(["playlist-prev"]).unwrap(); } pub fn seek(&self, new_position: f64) { match self .imp() .mpv .command(["seek", &new_position.to_string(), "absolute", "exact"]) { Ok(()) => {} Err(err) => { event!(Level::INFO, "queuing seek to {new_position}: {err}"); self.imp().queued_seek.set(Some(new_position)); self.notify("time-pos"); } } } pub fn playlist_move(&self, from: u32, to: u32) { // NOTE: for mpv, to refers to the "gap" right before i // so playlist-move i 0 makes a track the first // playlist-move i 1 makes a track the second // and playlist-move i playlist-count makes a track the last // (so if to is the position of another track, from is left behind to) self.imp() .mpv .command(["playlist-move", &from.to_string(), &to.to_string()]) .unwrap(); if from < to { // F1234T -> 1234FT let mut spliced = Vec::with_capacity((to - from) as usize); for i in from + 1..to { spliced.push(self.playlist_model().item(i).unwrap()); } spliced.push(self.playlist_model().item(from).unwrap()); self.playlist_model() .splice(from, spliced.len() as u32, &spliced); } else if to < from { // T1234F -> FT1234 let mut spliced = Vec::with_capacity((from - to + 1) as usize); spliced.push(self.playlist_model().item(from).unwrap()); spliced.push(self.playlist_model().item(to).unwrap()); for i in to + 1..from { spliced.push(self.playlist_model().item(i).unwrap()); } self.playlist_model() .splice(to, spliced.len() as u32, &spliced); } } }