mod imp { use crate::model::Song; use crate::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}; #[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 = u32, get = Self::volume, set = Self::set_volume)] _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 = i32, 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 = u32, get = Self::playlist_count)] _playlist_count: (), tick_callback: Cell>, 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>>, } 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(5, "time-pos").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: (), tick_callback: Default::default(), 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(), } } } #[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(); 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(); while let Some(event) = window.imp().mpv.wait_event(0.0) { use crate::mpv::Event; match event { Event::PropertyChange(event) => match event.reply_userdata { 0 => { assert_eq!(event.name, "volume"); window.notify("volume"); } 1 => { assert_eq!(event.name, "mute"); window.notify("mute"); } 2 => { assert_eq!(event.name, "pause"); window.notify("pause"); } 3 => { assert_eq!(event.name, "playlist-pos"); window.notify("playlist-pos"); } 4 => { assert_eq!(event.name, "idle-active"); window.notify("idle-active"); } 5 => { assert_eq!(event.name, "time-pos"); window.notify("time-pos"); } 6 => { assert_eq!(event.name, "playlist-count"); window.notify("playlist-count"); } 7 => { assert_eq!(event.name, "duration"); window.notify("duration"); } 8 => { assert_eq!(event.name, "path"); // sanity check match window.imp().mpv.get_property::("path") { Ok(path) => { assert_eq!(path, window.song().unwrap().stream_url()) } Err(err) if err.is_property_unavailable() => {} Err(err) => Err(err).unwrap(), } } _ => unreachable!(), }, Event::StartFile(_) => { event!(Level::INFO, "start file event"); window.notify("song"); window.imp().buffering_start(); let window2 = window.clone(); let song_id = window.song().unwrap().id(); window .imp() .loading_cover_handle .replace(Some(glib::spawn_future_local(async move { let api = window2.imp().api.borrow().as_ref().unwrap().clone(); let bytes = api .cover_art(&song_id) .await .expect("could not load cover art for song {song_id}"); match window2.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}", ); window2.set_playing_cover_art(Some(texture)); } _ => { event!(Level::WARN, "was too late to fetch cover for song {song_id}") }, } }))) .map(|handle| handle.abort()); } Event::Hook(event) => 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 window.imp().mpv.continue_hook(event.id).unwrap(); } _ => unreachable!(), }, Event::LogMessage(event) => { 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(), ), }; } 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::Seek => { event!(Level::INFO, "seek event"); window.imp().buffering_start(); } Event::PlaybackRestart => { event!(Level::INFO, "playback restart event"); window.imp().buffering_end(); if let Some(queued_seek) = window.imp().queued_seek.take() { // a seek was tried before and failed, try again now event!(Level::INFO, "performing queued seek to {queued_seek}"); window.seek(queued_seek); } } Event::EndFile(event) => { event!(Level::INFO, "end file event: {event:?}"); window.notify("song"); window.imp().buffering_end(); if let Err(err) = event.reason { event!(Level::ERROR, "end file error: {err}"); } // cancel queued seek always window.imp().queued_seek.set(None); window.set_playing_cover_art(None::); } _ => event!(Level::DEBUG, "unhandled {event:?}"), } } 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"); /* crate::mpris::Player::setup(conn.object_server(), &window.imp().playbin) .await .expect("could not serve mpris player"); FIXME */ // 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); 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) -> u32 { self.mpv .get_property::("volume") .unwrap() .try_into() .unwrap() } fn set_volume(&self, volume: u32) { 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) -> i32 { self.mpv .get_property::("playlist-pos") .unwrap() .try_into() .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) -> u32 { self.mpv .get_property::("playlist-count") .unwrap() .try_into() .unwrap() } fn song(&self) -> Option { if self.obj().playlist_pos() < 0 { None } else { let song: Song = self .obj() .playlist_model() .item(self.obj().playlist_pos() as u32) .unwrap() .dynamic_cast() .unwrap(); Some(song) } } fn buffering_start(&self) { let started_buffering = std::time::Instant::now(); self.tick_callback .replace(Some(self.obj().add_tick_callback(move |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(gtk::TickCallbackId::remove); } fn buffering_end(&self) { self.tick_callback.take().map(gtk::TickCallbackId::remove); self.playbar.set_show_pulse_bar(false); } } 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(); } } } 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); } } }