diff --git a/config/scripts.lua b/config/scripts.lua index 42211b4..ab1ca56 100644 --- a/config/scripts.lua +++ b/config/scripts.lua @@ -37,19 +37,17 @@ local function volume() } end --- 5s duration on Windows due to an issue mentioned in oled-applications/media/README.md -local SPOTIFY_DURATION = PLATFORM.Os == 'windows' and 5000 or 1000 -local function spotify() +local function media(source) return { widgets = { Widget.Bar { - value = SPOTIFY.Progress, - range = { min = 0, max = SPOTIFY.Duration }, + value = source.Progress, + range = { min = 0, max = source.Duration }, position = { x = 0, y = 0 }, size = { width = SCREEN.Width, height = 2 }, }, Widget.Text { - text = string.format("%s - %s", SPOTIFY.Artist, SPOTIFY.Title), + text = string.format("%s - %s", source.Artist, source.Title), scrolling = true, position = { x = 0, y = 2 }, size = { width = SCREEN.Width, height = 20 }, @@ -71,7 +69,18 @@ local function spotify() size = { width = SCREEN.Width, height = 2 }, }, }, - duration = SPOTIFY_DURATION, + duration = 1000, + } +end + +function make_media_layout(source) + return { + layout = function() return media(_ENV[source]) end, + run_on = { + string.format('%s.Artist', source), + string.format('%s.Progress', source), + string.format('%s.Title', source) + }, } end @@ -161,10 +170,7 @@ SCREEN_BUILDER layout = volume, run_on = { 'AUDIO.Input', 'AUDIO.Output' }, }, - { - layout = spotify, - run_on = { 'SPOTIFY.Artist', 'SPOTIFY.Progress', 'SPOTIFY.Title' }, - }, + make_media_layout('MEDIA'), { layout = clock, run_on = { 'CLOCK.Seconds' }, diff --git a/omni-led-applications/media/README.md b/omni-led-applications/media/README.md index 8c15418..82da8f3 100644 --- a/omni-led-applications/media/README.md +++ b/omni-led-applications/media/README.md @@ -1,6 +1,6 @@ # Media -Media application provides information about currently playing media, e.g. title, artist, duration etc. +Media application provides information about currently playing media, e.g., title, artist, duration, etc. ## Running @@ -14,13 +14,13 @@ Media expects three arguments - `a`/`address` - server address - Optional: - `m`/`mode` - reporting mode - `individual`, `focused` or `both`. - Default: `both`. - Described in [reporting mode](#reporting-mode). + Default: `both`. + Described in [reporting mode](#reporting-mode). - `map` - map input application name to an event name, e.g. `--map "my_app_name=APP"`. Can be passed multiple - times. Target name must be an uppercase alphanumeric string, that can contain underscores and cannot start with a - number. - Default: `[]`. - Describen in [application name mapping](#application-name-mapping). + times. Target name must be an uppercase alphanumeric string that can contain underscores and cannot start with a + number. + Default: `[]`. + Described in [application name mapping](#application-name-mapping). ## Reporting mode @@ -36,15 +36,15 @@ All updates will be sent with event name `MEDIA`, regardless of source applicati ### Both -Report events in both ways - individual per application and combined for currently focused application. +Report events in both ways – individual per application and combined for currently focused application. ## Application name mapping When sending events in [individual](#individual) mode, application names will be mapped to event names. If mapping was provided as a command line parameter, then it will use the target name from that mapping. -If mapping was not provided, source application name will be converted in the following manner: +If mapping was not provided, the source application name will be converted in the following manner: -- If name starts with a digit it will be prefixed with an underscore. +- If the name starts with a digit, it will be prefixed with an underscore. - All ascii letters will be converted to uppercase. - All non-alphanumeric characters will be converted to underscores. @@ -63,10 +63,10 @@ Examples: Media sends a single type of event, and its name depends on the selected [mode](#reporting-mode). -> There is a discrepancy in event frequency between current implementations on Windows and Linux operating systems. -> On Windows the interval seems to be around 4 seconds and on Linux it's a fixed update interval of 1 second. +> Apps report the updates with varying frequencies. +> This application tracks the playback rate and duration since the last update to send updates at least once a second. -> Availability of event fields depends entirely on the media source. Be sure to check if a field is present when +> The availability of event fields depends entirely on the media source. Be sure to check if a field is present when > handling media events. `MEDIA` or ``: table @@ -76,3 +76,4 @@ Media sends a single type of event, and its name depends on the selected [mode]( - `Progress`: integer (value in milliseconds), - `Duration`: integer (value in milliseconds), - `Playing`: bool, +- `Rate`: float (Playback speed multiplier - `1.0` for regular speed) diff --git a/omni-led-applications/media/src/media/linux/media_impl.rs b/omni-led-applications/media/src/media/linux/media_impl.rs index 83d48a5..667128f 100644 --- a/omni-led-applications/media/src/media/linux/media_impl.rs +++ b/omni-led-applications/media/src/media/linux/media_impl.rs @@ -110,6 +110,7 @@ impl MediaImpl { let title = metadata.title().unwrap_or_default(); let progress = player.get_position().unwrap_or_default(); let duration = metadata.length().unwrap_or_default(); + let rate = player.get_playback_rate().unwrap_or(1.0); Ok(SessionData { artist: artist.to_string(), @@ -117,6 +118,7 @@ impl MediaImpl { progress, duration, playing: true, + rate, }) } } diff --git a/omni-led-applications/media/src/media/session_data.rs b/omni-led-applications/media/src/media/session_data.rs index b283dab..7cc3826 100644 --- a/omni-led-applications/media/src/media/session_data.rs +++ b/omni-led-applications/media/src/media/session_data.rs @@ -11,6 +11,7 @@ pub struct SessionData { #[proto(transform = Self::duration_into_ms)] pub duration: Duration, pub playing: bool, + pub rate: f64, } impl SessionData { diff --git a/omni-led-applications/media/src/media/windows/global_system_media.rs b/omni-led-applications/media/src/media/windows/global_system_media.rs index 581872f..6225589 100644 --- a/omni-led-applications/media/src/media/windows/global_system_media.rs +++ b/omni-led-applications/media/src/media/windows/global_system_media.rs @@ -49,6 +49,21 @@ impl GlobalSystemMedia { Self::register_session_handlers(&session, &tx, handle.clone()); } + match manager.GetCurrentSession() { + Ok(session) => { + tx.send(Message::CurrentSessionChanged(Some(session))) + .await + .unwrap(); + } + Err(err) => { + // Error code will be OK if there are no media sessions started, + // but otherwise the query succeeded + if err.code().is_err() { + panic!("{err}"); + } + } + } + let sessions = Arc::new(Mutex::new(sessions)); Self::register_global_handlers(tx, handle, &manager, &sessions); } diff --git a/omni-led-applications/media/src/media/windows/media_impl.rs b/omni-led-applications/media/src/media/windows/media_impl.rs index 282cbaf..8870caf 100644 --- a/omni-led-applications/media/src/media/windows/media_impl.rs +++ b/omni-led-applications/media/src/media/windows/media_impl.rs @@ -1,11 +1,11 @@ +use log::warn; use std::collections::HashMap; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::mpsc; use tokio::sync::mpsc::{Receiver, Sender}; -use windows::Foundation::TimeSpan; use windows::Media::Control::{ GlobalSystemMediaTransportControlsSession, - GlobalSystemMediaTransportControlsSessionPlaybackStatus, + GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus, }; use crate::Data; @@ -16,6 +16,11 @@ pub struct MediaImpl { tx: Sender, } +struct SessionState { + data: SessionData, + last_update: Instant, +} + impl MediaImpl { pub fn new(tx: Sender) -> Self { Self { tx } @@ -35,76 +40,98 @@ impl MediaImpl { } async fn run_message_loop(tx: Sender, mut rx: Receiver) { - let mut sessions: HashMap = HashMap::new(); + let mut sessions: HashMap = HashMap::new(); let mut current_session: Option = None; - while let Some(message) = rx.recv().await { - match message { - Message::SessionAdded(session) => { - let name = Self::get_name(&session); - let (artist, title) = Self::get_song(&session).await; - let (progress, duration) = Self::get_progress(&session); - let playing = Self::is_playing(&session); - - sessions.insert( - name, - SessionData { - artist, - title, - progress, - duration, - playing, - }, - ); - } - Message::SessionRemoved(session) => { - let name = Self::get_name(&session); - sessions.remove(&name); - } - Message::CurrentSessionChanged(session) => { - current_session = match session { - Some(session) => Some(Self::get_name(&session)), - None => None, - }; - } - Message::PlaybackInfoChanged(session) => { - let name = Self::get_name(&session); + let mut heartbeat = tokio::time::interval(Duration::from_secs(1)); - match sessions.get_mut(&name) { - Some(entry) => { - entry.playing = Self::is_playing(&session); + loop { + tokio::select! { + message = rx.recv() => { + match message { + Some(Message::SessionAdded(session)) => { + let name = Self::get_name(&session); + let (artist, title) = Self::get_song(&session).await; + let (progress, duration) = Self::get_progress(&session); + let (playing, rate) = Self::playback_info(&session); + + sessions.insert( + name, + SessionState { + data: SessionData { + artist, + title, + progress, + duration, + playing, + rate, + }, + last_update: Instant::now(), + }, + ); + } + Some(Message::SessionRemoved(session)) => { + let name = Self::get_name(&session); + sessions.remove(&name); + } + Some(Message::CurrentSessionChanged(session)) => { + current_session = match session { + Some(session) => Some(Self::get_name(&session)), + None => None, + }; + } + Some(Message::PlaybackInfoChanged(session)) => { + let name = Self::get_name(&session); - Self::send_data(&tx, name, entry.clone(), ¤t_session).await; + if let Some(state) = sessions.get_mut(&name) { + let (playing, rate) = Self::playback_info(&session); + state.data.playing = playing; + state.data.rate = rate; + state.last_update = Instant::now(); + + Self::send_data(&tx, name, state.data.clone(), ¤t_session).await; + } } - None => {} - } - } - Message::MediaPropertiesChanged(session) => { - let name = Self::get_name(&session); + Some(Message::MediaPropertiesChanged(session)) => { + let name = Self::get_name(&session); - match sessions.get_mut(&name) { - Some(entry) => { - let (artist, title) = Self::get_song(&session).await; - entry.artist = artist; - entry.title = title; + if let Some(state) = sessions.get_mut(&name) { + let (artist, title) = Self::get_song(&session).await; + state.data.artist = artist; + state.data.title = title; - Self::send_data(&tx, name, entry.clone(), ¤t_session).await; + Self::send_data(&tx, name, state.data.clone(), ¤t_session).await; + } } - None => {} + Some(Message::TimelinePropertiesChanged(session)) => { + let name = Self::get_name(&session); + + if let Some(state) = sessions.get_mut(&name) { + let (progress, duration) = Self::get_progress(&session); + state.data.progress = progress; + state.data.duration = duration; + state.last_update = Instant::now(); + + Self::send_data(&tx, name, state.data.clone(), ¤t_session).await; + } + } + None => break, } } - Message::TimelinePropertiesChanged(session) => { - let name = Self::get_name(&session); - match sessions.get_mut(&name) { - Some(entry) => { - let (progress, duration) = Self::get_progress(&session); - entry.progress = progress; - entry.duration = duration; + _ = heartbeat.tick() => { + for (name, state) in &sessions { + if state.data.playing && state.data.rate > 0.0 { + let elapsed = state.last_update.elapsed(); + let progress_delta = elapsed.mul_f64(state.data.rate); + + let mut data = state.data.clone(); - Self::send_data(&tx, name, entry.clone(), ¤t_session).await; + data.progress = (data.progress + progress_delta) + .min(state.data.duration); + + Self::send_data(&tx, name.clone(), data, ¤t_session).await; } - None => {} } } } @@ -116,32 +143,70 @@ impl MediaImpl { } async fn get_song(session: &GlobalSystemMediaTransportControlsSession) -> (String, String) { - let properties = session.TryGetMediaPropertiesAsync().unwrap().await.unwrap(); - let artist = properties.Artist().unwrap().to_string_lossy(); - let title = properties.Title().unwrap().to_string_lossy(); - - (artist, title) + const DEFAULT_STR: &str = "N/A"; + + match session.TryGetMediaPropertiesAsync() { + Ok(operation) => match operation.await { + Ok(properties) => Ok(( + properties + .Artist() + .and_then(|x| Ok(x.to_string_lossy())) + .unwrap_or(DEFAULT_STR.to_string()), + properties + .Title() + .and_then(|x| Ok(x.to_string_lossy())) + .unwrap_or(DEFAULT_STR.to_string()), + )), + Err(err) => Err(err), + }, + Err(err) => Err(err), + } + .unwrap_or_else(|err| { + warn!("{err}"); + (DEFAULT_STR.to_string(), DEFAULT_STR.to_string()) + }) } fn get_progress(session: &GlobalSystemMediaTransportControlsSession) -> (Duration, Duration) { - let to_duration = |timespan: TimeSpan| { - let ms = timespan.Duration / 10000; - Duration::from_millis(ms as u64) - }; - - let properties = session.GetTimelineProperties().unwrap(); - let progress = to_duration(properties.Position().unwrap()); - let duration = to_duration(properties.EndTime().unwrap()); - - (progress, duration) + const DEFAULT_POSITION: Duration = Duration::from_millis(0); + const DEFAULT_END: Duration = Duration::from_millis(1); + + match session.GetTimelineProperties() { + Ok(properties) => ( + properties + .Position() + .and_then(|x| Ok(x.into())) + .unwrap_or(DEFAULT_POSITION), + properties + .EndTime() + .and_then(|x| Ok(x.into())) + .unwrap_or(DEFAULT_END), + ), + Err(err) => { + warn!("{err}"); + (DEFAULT_POSITION, DEFAULT_END) + } + } } - fn is_playing(session: &GlobalSystemMediaTransportControlsSession) -> bool { - let info = session.GetPlaybackInfo().unwrap(); - let playing = info.PlaybackStatus().unwrap() - == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing; - - playing + fn playback_info(session: &GlobalSystemMediaTransportControlsSession) -> (bool, f64) { + const DEFAULT_STATUS: bool = false; + const DEFAULT_RATE: f64 = 1.0; + + match session.GetPlaybackInfo() { + Ok(info) => ( + info.PlaybackStatus() + .and_then(|x| Ok(x == PlaybackStatus::Playing)) + .unwrap_or(DEFAULT_STATUS), + info.PlaybackRate() + .and_then(|x| x.Value()) + .unwrap_or(DEFAULT_RATE), + ), + Err(err) => { + warn!("{err}"); + (DEFAULT_STATUS, DEFAULT_RATE) + } + } } fn is_current(name: &String, current: &Option) -> bool {