diff --git a/README.md b/README.md
index 8abe1b12..80a086ac 100644
--- a/README.md
+++ b/README.md
@@ -358,7 +358,8 @@ No config options.
| Variable | Description | Return type | Supported OS |
| ------------------- | ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `session` | Currently playing media session. | `MediaSession \| null` | |
+| `currentSession` | Currently playing media session. | `MediaSession \| null` | |
+| `allSessions` | All active media sessions. | `MediaSession[]` | |
#### Return types
@@ -366,15 +367,17 @@ No config options.
| Variable | Description | Return type |
| ------------------ | ----------------------------- | ----------------------- |
-| `title` | TODO | `string` |
-| `artist` | TODO | `string \| null` |
-| `albumTitle` | TODO | `string \| null` |
-| `albumArtist` | TODO | `string \| null` |
-| `trackNumber` | TODO | `number` |
-| `startTime` | TODO | `number` |
-| `endTime` | TODO | `number` |
-| `position` | TODO | `number` |
-| `isPlaying` | TODO | `boolean` |
+| `sessionId` | ID of the media session. | `string` |
+| `title` | Title of the media session. | `string` |
+| `artist` | Artist of the media session. | `string \| null` |
+| `albumTitle` | Album title of the media session. | `string \| null` |
+| `albumArtist` | Album artist of the media session. | `string \| null` |
+| `trackNumber` | Track number of the media session. | `number` |
+| `startTime` | Start time of the media session. | `number` |
+| `endTime` | End time of the media session. | `number` |
+| `position` | Position of the media session. | `number` |
+| `isPlaying` | Whether the media session is playing. | `boolean` |
+| `isCurrentSession` | Whether this is the currently active session (i.e. `currentSession`). | `boolean` |
#### `DataSizeMeasure`
diff --git a/examples/boilerplate-react-buildless/index.html b/examples/boilerplate-react-buildless/index.html
index ca823438..f76e9e1a 100644
--- a/examples/boilerplate-react-buildless/index.html
+++ b/examples/boilerplate-react-buildless/index.html
@@ -51,8 +51,11 @@
return (
- Media: {output.media?.session?.title} -
- {output.media?.session?.artist}
+ Media: {output.media?.currentSession?.title} -
+ {output.media?.currentSession?.artist}
+ output.media?.togglePlayPause()}>
+ ⏯
+
CPU usage: {output.cpu?.usage}
diff --git a/examples/boilerplate-solid-ts/src/index.tsx b/examples/boilerplate-solid-ts/src/index.tsx
index 1a6ce453..03c0aa35 100644
--- a/examples/boilerplate-solid-ts/src/index.tsx
+++ b/examples/boilerplate-solid-ts/src/index.tsx
@@ -27,8 +27,9 @@ function App() {
{output.audio?.defaultPlaybackDevice?.volume}
- Media: {output.media?.session?.title} -
- {output.media?.session?.artist}
+ Media: {output.media?.currentSession?.title} -
+ {output.media?.currentSession?.artist}
+ output.media?.togglePlayPause()}>⏯
CPU usage: {output.cpu?.usage}
diff --git a/packages/client-api/src/desktop/desktop-commands.ts b/packages/client-api/src/desktop/desktop-commands.ts
index 4df3de77..14474228 100644
--- a/packages/client-api/src/desktop/desktop-commands.ts
+++ b/packages/client-api/src/desktop/desktop-commands.ts
@@ -14,10 +14,25 @@ export const desktopCommands = {
startPreset,
listenProvider,
unlistenProvider,
+ callProviderFunction,
setAlwaysOnTop,
setSkipTaskbar,
};
+export type ProviderFunction = MediaFunction;
+
+export interface MediaFunction {
+ type: 'media';
+ function: {
+ name: 'play' | 'pause' | 'toggle_play_pause' | 'next' | 'previous';
+ args: MediaControlArgs;
+ };
+}
+
+export interface MediaControlArgs {
+ sessionId?: string;
+}
+
function startWidget(
configPath: string,
placement: WidgetPlacement,
@@ -43,6 +58,16 @@ function unlistenProvider(configHash: string): Promise
{
return invoke('unlisten_provider', { configHash });
}
+function callProviderFunction(
+ configHash: string,
+ fn: ProviderFunction,
+): Promise {
+ return invoke('call_provider_function', {
+ configHash,
+ function: fn,
+ });
+}
+
function setAlwaysOnTop(): Promise {
return invoke('set_always_on_top');
}
diff --git a/packages/client-api/src/providers/media/create-media-provider.ts b/packages/client-api/src/providers/media/create-media-provider.ts
index 0398bbad..eb95f65d 100644
--- a/packages/client-api/src/providers/media/create-media-provider.ts
+++ b/packages/client-api/src/providers/media/create-media-provider.ts
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { createBaseProvider } from '../create-base-provider';
-import { onProviderEmit } from '~/desktop';
+import { desktopCommands, onProviderEmit } from '~/desktop';
import type {
+ MediaControlArgs,
MediaOutput,
MediaProvider,
MediaProviderConfig,
@@ -17,12 +18,63 @@ export function createMediaProvider(
const mergedConfig = mediaProviderConfigSchema.parse(config);
return createBaseProvider(mergedConfig, async queue => {
- return onProviderEmit(mergedConfig, ({ result }) => {
- if ('error' in result) {
- queue.error(result.error);
- } else {
- queue.output(result.output);
- }
- });
+ return onProviderEmit(
+ mergedConfig,
+ ({ result, configHash }) => {
+ if ('error' in result) {
+ queue.error(result.error);
+ } else {
+ queue.output({
+ ...result.output,
+ session: result.output.currentSession,
+ play: (args?: MediaControlArgs) => {
+ desktopCommands.callProviderFunction(configHash, {
+ type: 'media',
+ function: {
+ name: 'play',
+ args: args ?? {},
+ },
+ });
+ },
+ pause: (args?: MediaControlArgs) => {
+ desktopCommands.callProviderFunction(configHash, {
+ type: 'media',
+ function: {
+ name: 'pause',
+ args: args ?? {},
+ },
+ });
+ },
+ togglePlayPause: (args?: MediaControlArgs) => {
+ desktopCommands.callProviderFunction(configHash, {
+ type: 'media',
+ function: {
+ name: 'toggle_play_pause',
+ args: args ?? {},
+ },
+ });
+ },
+ next: (args?: MediaControlArgs) => {
+ desktopCommands.callProviderFunction(configHash, {
+ type: 'media',
+ function: {
+ name: 'next',
+ args: args ?? {},
+ },
+ });
+ },
+ previous: (args?: MediaControlArgs) => {
+ desktopCommands.callProviderFunction(configHash, {
+ type: 'media',
+ function: {
+ name: 'previous',
+ args: args ?? {},
+ },
+ });
+ },
+ });
+ }
+ },
+ );
});
}
diff --git a/packages/client-api/src/providers/media/media-provider-types.ts b/packages/client-api/src/providers/media/media-provider-types.ts
index b6bb1695..b1973cfd 100644
--- a/packages/client-api/src/providers/media/media-provider-types.ts
+++ b/packages/client-api/src/providers/media/media-provider-types.ts
@@ -5,11 +5,24 @@ export interface MediaProviderConfig {
}
export interface MediaOutput {
+ /** @deprecated Use {@link currentSession} instead */
session: MediaSession | null;
+ currentSession: MediaSession | null;
+ allSessions: MediaSession[];
+ play(args?: MediaControlArgs): void;
+ pause(args?: MediaControlArgs): void;
+ togglePlayPause(args?: MediaControlArgs): void;
+ next(args?: MediaControlArgs): void;
+ previous(args?: MediaControlArgs): void;
+}
+
+export interface MediaControlArgs {
+ sessionId?: string;
}
export interface MediaSession {
- title: string;
+ sessionId: string;
+ title: string | null;
artist: string | null;
albumTitle: string | null;
albumArtist: string | null;
@@ -18,6 +31,7 @@ export interface MediaSession {
endTime: number;
position: number;
isPlaying: boolean;
+ isCurrentSession: boolean;
}
export type MediaProvider = Provider;
diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml
index 086fe7ad..01e0197d 100644
--- a/packages/desktop/Cargo.toml
+++ b/packages/desktop/Cargo.toml
@@ -43,9 +43,10 @@ komorebi-client = { git = "https://github.com/LGUG2Z/komorebi", tag = "v0.1.28"
windows-core = "0.58"
windows = { version = "0.58", features = [
"Foundation",
+ "Foundation_Collections",
"implement",
- "Win32_Devices_FunctionDiscovery",
"Media_Control",
+ "Win32_Devices_FunctionDiscovery",
"Win32_Globalization",
"Win32_Media",
"Win32_Media_Audio",
diff --git a/packages/desktop/src/providers/media/media_provider.rs b/packages/desktop/src/providers/media/media_provider.rs
index 008858ed..707dbfea 100644
--- a/packages/desktop/src/providers/media/media_provider.rs
+++ b/packages/desktop/src/providers/media/media_provider.rs
@@ -1,11 +1,9 @@
-use std::{
- sync::{Arc, Mutex},
- time,
-};
+use std::collections::{HashMap, HashSet};
-use async_trait::async_trait;
+use anyhow::Context;
+use crossbeam::channel::{unbounded, Receiver, Sender};
use serde::{Deserialize, Serialize};
-use tracing::{debug, error};
+use tracing::{debug, warn};
use windows::{
Foundation::{EventRegistrationToken, TypedEventHandler},
Media::Control::{
@@ -16,7 +14,8 @@ use windows::{
};
use crate::providers::{
- CommonProviderState, Provider, ProviderEmitter, RuntimeType,
+ CommonProviderState, MediaFunction, Provider, ProviderFunction,
+ ProviderFunctionResponse, ProviderInputMsg, RuntimeType,
};
#[derive(Deserialize, Debug)]
@@ -26,13 +25,15 @@ pub struct MediaProviderConfig {}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaOutput {
- pub session: Option,
+ pub current_session: Option,
+ pub all_sessions: Vec,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaSession {
- pub title: String,
+ pub session_id: String,
+ pub title: Option,
pub artist: Option,
pub album_title: Option,
pub album_artist: Option,
@@ -41,17 +42,61 @@ pub struct MediaSession {
pub end_time: u64,
pub position: u64,
pub is_playing: bool,
+ pub is_current_session: bool,
+}
+
+impl Default for MediaSession {
+ fn default() -> Self {
+ Self {
+ session_id: "".to_string(),
+ title: None,
+ artist: None,
+ album_title: None,
+ album_artist: None,
+ track_number: 0,
+ start_time: 0,
+ end_time: 0,
+ position: 0,
+ is_playing: false,
+ is_current_session: false,
+ }
+ }
+}
+
+/// Events that can be emitted from media session state changes.
+#[derive(Debug)]
+enum MediaSessionEvent {
+ SessionAddOrRemove,
+ CurrentSessionChanged,
+ PlaybackInfoChanged(String),
+ MediaPropertiesChanged(String),
+ TimelinePropertiesChanged(String),
}
-#[derive(Clone, Debug)]
+/// Holds event registration tokens for media session callbacks.
+///
+/// These need to be cleaned up when the session changes.
+#[derive(Debug)]
struct EventTokens {
- playback_info_changed_token: EventRegistrationToken,
- media_properties_changed_token: EventRegistrationToken,
- timeline_properties_changed_token: EventRegistrationToken,
+ playback: EventRegistrationToken,
+ properties: EventRegistrationToken,
+ timeline: EventRegistrationToken,
+}
+
+/// Holds the state of a media session.
+#[derive(Debug)]
+struct SessionState {
+ session: GsmtcSession,
+ tokens: EventTokens,
+ output: MediaSession,
}
pub struct MediaProvider {
common: CommonProviderState,
+ current_session_id: Option,
+ session_states: HashMap,
+ event_sender: Sender,
+ event_receiver: Receiver,
}
impl MediaProvider {
@@ -59,208 +104,425 @@ impl MediaProvider {
_config: MediaProviderConfig,
common: CommonProviderState,
) -> MediaProvider {
- MediaProvider { common }
+ let (event_sender, event_receiver) = unbounded();
+
+ Self {
+ common,
+ current_session_id: None,
+ session_states: HashMap::new(),
+ event_sender,
+ event_receiver,
+ }
}
- fn emit_media_info(
- session: Option<&GsmtcSession>,
- emitter: &ProviderEmitter,
- ) {
- let media_output = Self::media_output(session);
- emitter.emit_output(media_output);
- }
+ /// Main entry point that sets up the media session manager and runs the
+ /// event loop.
+ fn create_session_manager(&mut self) -> anyhow::Result<()> {
+ debug!("Getting media session manager.");
+ let manager = GsmtcManager::RequestAsync()?.get()?;
- fn media_output(
- session: Option<&GsmtcSession>,
- ) -> anyhow::Result {
- Ok(MediaOutput {
- session: match session {
- Some(session) => Self::media_session(session)?,
- None => None,
- },
- })
+ self.register_session_change_callbacks(&manager)?;
+ self.update_session_states(&manager)?;
+ self.update_current_session(&manager)?;
+
+ // Emit initial output.
+ self.emit_output();
+
+ loop {
+ crossbeam::select! {
+ recv(self.event_receiver) -> event => {
+ if let Ok(event) = event {
+ debug!("Got media session event: {:?}", event);
+
+ if let Err(err) = self.handle_event(event) {
+ warn!("Error handling media session event: {}", err);
+ }
+ }
+ }
+ recv(self.common.input.sync_rx) -> input => {
+ match input {
+ Ok(ProviderInputMsg::Stop) => {
+ break;
+ }
+ Ok(ProviderInputMsg::Function(
+ ProviderFunction::Media(media_function),
+ sender,
+ )) => {
+ let res = self.handle_function(media_function).map_err(|err| err.to_string());
+ sender.send(res).unwrap();
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ Ok(())
}
- fn media_session(
- session: &GsmtcSession,
- ) -> anyhow::Result> {
- let media_properties = session.TryGetMediaPropertiesAsync()?.get()?;
- let timeline_properties = session.GetTimelineProperties()?;
- let playback_info = session.GetPlaybackInfo()?;
-
- let title = media_properties.Title()?.to_string();
- let artist = media_properties.Artist()?.to_string();
- let album_title = media_properties.AlbumTitle()?.to_string();
- let album_artist = media_properties.AlbumArtist()?.to_string();
-
- // GSMTC can have a valid session, but return empty string for all
- // media properties. Check that we at least have a valid title.
- if title.is_empty() {
- return Ok(None);
+ /// Handles a media session event.
+ fn handle_event(
+ &mut self,
+ event: MediaSessionEvent,
+ ) -> anyhow::Result<()> {
+ match event {
+ MediaSessionEvent::CurrentSessionChanged => {
+ let manager = GsmtcManager::RequestAsync()?.get()?;
+ self.update_current_session(&manager)?;
+ }
+ MediaSessionEvent::SessionAddOrRemove => {
+ let manager = GsmtcManager::RequestAsync()?.get()?;
+ self.update_session_states(&manager)?;
+ }
+ MediaSessionEvent::PlaybackInfoChanged(id) => {
+ if let Some(session_state) = self.session_states.get_mut(&id) {
+ Self::update_playback_info(
+ &mut session_state.output,
+ &session_state.session,
+ )?;
+ }
+ }
+ MediaSessionEvent::MediaPropertiesChanged(id) => {
+ if let Some(session_state) = self.session_states.get_mut(&id) {
+ Self::update_media_properties(
+ &mut session_state.output,
+ &session_state.session,
+ )?;
+ }
+ }
+ MediaSessionEvent::TimelinePropertiesChanged(id) => {
+ if let Some(session_state) = self.session_states.get_mut(&id) {
+ Self::update_timeline_properties(
+ &mut session_state.output,
+ &session_state.session,
+ )?;
+ }
+ }
}
- let is_playing =
- playback_info.PlaybackStatus()? == GsmtcPlaybackStatus::Playing;
- let start_time =
- timeline_properties.StartTime()?.Duration as u64 / 10_000_000;
- let end_time =
- timeline_properties.EndTime()?.Duration as u64 / 10_000_000;
- let position =
- timeline_properties.Position()?.Duration as u64 / 10_000_000;
-
- Ok(Some(MediaSession {
- title,
- artist: (!artist.is_empty()).then_some(artist),
- album_title: (!album_title.is_empty()).then_some(album_title),
- album_artist: (!album_artist.is_empty()).then_some(album_artist),
- track_number: media_properties.TrackNumber()? as u32,
- start_time,
- end_time,
- position,
- is_playing,
- }))
+ // Emit new output after handling the event.
+ self.emit_output();
+
+ Ok(())
}
- fn create_session_manager(&mut self) -> anyhow::Result<()> {
- debug!("Creating media session manager.");
+ /// Handles an incoming media provider function call.
+ fn handle_function(
+ &mut self,
+ function: MediaFunction,
+ ) -> anyhow::Result {
+ let args = match &function {
+ MediaFunction::Play(args)
+ | MediaFunction::Pause(args)
+ | MediaFunction::TogglePlayPause(args)
+ | MediaFunction::Next(args)
+ | MediaFunction::Previous(args) => args,
+ };
- // Find the current GSMTC session & add listeners.
- let session_manager = GsmtcManager::RequestAsync()?.get()?;
- let current_session = session_manager.GetCurrentSession().ok();
+ // Get target session - use specified ID or current session.
+ let session_state = if let Some(id) = &args.session_id {
+ self
+ .session_states
+ .get(id)
+ .context("Specified session not found.")?
+ } else {
+ self
+ .current_session_id
+ .as_ref()
+ .and_then(|id| self.session_states.get(id))
+ .context("No active session.")?
+ };
- let event_tokens = match ¤t_session {
- Some(session) => {
- Some(Self::add_session_listeners(session, &self.common.emitter)?)
+ match function {
+ MediaFunction::Play(_) => {
+ session_state.session.TryPlayAsync()?.get()?;
+ }
+ MediaFunction::Pause(_) => {
+ session_state.session.TryPauseAsync()?.get()?;
+ }
+ MediaFunction::TogglePlayPause(_) => {
+ session_state.session.TryTogglePlayPauseAsync()?.get()?;
+ }
+ MediaFunction::Next(_) => {
+ session_state.session.TrySkipNextAsync()?.get()?;
+ }
+ MediaFunction::Previous(_) => {
+ session_state.session.TrySkipPreviousAsync()?.get()?;
}
- None => None,
};
- // Emit initial media info.
- Self::emit_media_info(current_session.as_ref(), &self.common.emitter);
-
- let current_session = Arc::new(Mutex::new(current_session));
- let event_tokens = Arc::new(Mutex::new(event_tokens));
- let emitter = self.common.emitter.clone();
-
- // Clean up & rebind listeners when session changes.
- let session_changed_handler =
- TypedEventHandler::new(move |_: &Option, _| {
- {
- let mut current_session = current_session.lock().unwrap();
- let mut event_tokens = event_tokens.lock().unwrap();
-
- // Remove listeners from the previous session.
- if let (Some(session), Some(token)) =
- (current_session.as_ref(), event_tokens.as_ref())
- {
- if let Err(err) =
- Self::remove_session_listeners(session, token)
- {
- error!("Failed to remove session listeners: {}", err);
- }
- }
+ Ok(ProviderFunctionResponse::Null)
+ }
- // Set up new session.
- let new_session =
- GsmtcManager::RequestAsync()?.get()?.GetCurrentSession()?;
+ /// Registers event callbacks with the session manager.
+ ///
+ /// - `CurrentSessionChanged`: for when the active media session changes
+ /// (e.g. when switching between media players).
+ /// - `SessionAddOrRemove`: for when the list of available media sessions
+ /// changes (e.g. when a media player is opened or closed).
+ fn register_session_change_callbacks(
+ &self,
+ manager: &GsmtcManager,
+ ) -> anyhow::Result<()> {
+ // Handler for current session changes.
+ manager.CurrentSessionChanged(&TypedEventHandler::new({
+ let sender = self.event_sender.clone();
+ move |_, _| {
+ sender
+ .send(MediaSessionEvent::CurrentSessionChanged)
+ .unwrap();
+ Ok(())
+ }
+ }))?;
- let tokens =
- Self::add_session_listeners(&new_session, &emitter)?;
+ // Handler for a session is added or removed.
+ manager.SessionsChanged(&TypedEventHandler::new({
+ let sender = self.event_sender.clone();
+ move |_, _| {
+ sender.send(MediaSessionEvent::SessionAddOrRemove).unwrap();
+ Ok(())
+ }
+ }))?;
- Self::emit_media_info(Some(&new_session), &emitter);
+ Ok(())
+ }
- *current_session = Some(new_session);
- *event_tokens = Some(tokens);
- }
+ /// Updates the state of all media sessions.
+ fn update_session_states(
+ &mut self,
+ manager: &GsmtcManager,
+ ) -> anyhow::Result<()> {
+ let sessions = manager.GetSessions()?;
+ let mut found_ids: HashSet = HashSet::new();
- Ok(())
- });
+ // Handle new sessions and track existing sessions to detect removals.
+ for session in sessions {
+ let session_id = session.SourceAppUserModelId()?.to_string();
+ found_ids.insert(session_id.clone());
- session_manager.CurrentSessionChanged(&session_changed_handler)?;
+ if !self.session_states.contains_key(&session_id) {
+ debug!("New media session detected: {}", session_id);
- loop {
- std::thread::sleep(time::Duration::from_secs(1));
+ let session_state = SessionState {
+ tokens: self
+ .register_session_callbacks(&session, &session_id)?,
+ output: Self::to_media_session_output(&session, &session_id)?,
+ session,
+ };
+
+ self.session_states.insert(session_id, session_state);
+ }
}
+
+ let removed_ids = self
+ .session_states
+ .keys()
+ .filter(|id| !found_ids.contains(*id))
+ .cloned()
+ .collect::>();
+
+ // Remove sessions that no longer exist.
+ for session_id in &removed_ids {
+ if let Some(session_state) = self.session_states.remove(session_id) {
+ debug!("Media session ended: {}", session_id);
+ Self::remove_session_listeners(
+ &session_state.session,
+ &session_state.tokens,
+ );
+ }
+ }
+
+ Ok(())
}
- fn remove_session_listeners(
- session: &GsmtcSession,
- tokens: &EventTokens,
+ /// Updates the current media session ID and marks the correct session as
+ /// the current one.
+ fn update_current_session(
+ &mut self,
+ manager: &GsmtcManager,
) -> anyhow::Result<()> {
- session.RemoveMediaPropertiesChanged(
- tokens.media_properties_changed_token,
- )?;
+ self.current_session_id = manager
+ .GetCurrentSession()
+ .ok()
+ .and_then(|session| session.SourceAppUserModelId().ok())
+ .map(|session_id| session_id.to_string());
+
+ for (session_id, state) in self.session_states.iter_mut() {
+ state.output.is_current_session =
+ Some(session_id) == self.current_session_id.as_ref();
+ }
- session
- .RemovePlaybackInfoChanged(tokens.playback_info_changed_token)?;
+ Ok(())
+ }
- session.RemoveTimelinePropertiesChanged(
- tokens.timeline_properties_changed_token,
- )?;
+ /// Registers event callbacks for media session state changes.
+ ///
+ /// Returns tokens needed for cleanup when the session ends.
+ fn register_session_callbacks(
+ &self,
+ session: &GsmtcSession,
+ session_id: &str,
+ ) -> anyhow::Result {
+ Ok(EventTokens {
+ playback: session.PlaybackInfoChanged(&TypedEventHandler::new({
+ let sender = self.event_sender.clone();
+ let session_id = session_id.to_string();
+ move |_, _| {
+ sender
+ .send(MediaSessionEvent::PlaybackInfoChanged(
+ session_id.clone(),
+ ))
+ .unwrap();
+
+ Ok(())
+ }
+ }))?,
+ properties: session.MediaPropertiesChanged(
+ &TypedEventHandler::new({
+ let sender = self.event_sender.clone();
+ let session_id = session_id.to_string();
+ move |_, _| {
+ sender
+ .send(MediaSessionEvent::MediaPropertiesChanged(
+ session_id.clone(),
+ ))
+ .unwrap();
+
+ Ok(())
+ }
+ }),
+ )?,
+ timeline: session.TimelinePropertiesChanged(
+ &TypedEventHandler::new({
+ let sender = self.event_sender.clone();
+ let session_id = session_id.to_string();
+ move |_, _| {
+ sender
+ .send(MediaSessionEvent::TimelinePropertiesChanged(
+ session_id.clone(),
+ ))
+ .unwrap();
+
+ Ok(())
+ }
+ }),
+ )?,
+ })
+ }
- Ok(())
+ /// Cleans up event listeners from the given session.
+ fn remove_session_listeners(
+ session: &GsmtcSession,
+ tokens: &EventTokens,
+ ) {
+ let _ = session.RemovePlaybackInfoChanged(tokens.playback);
+ let _ = session.RemoveMediaPropertiesChanged(tokens.properties);
+ let _ = session.RemoveTimelinePropertiesChanged(tokens.timeline);
}
- fn add_session_listeners(
+ /// Emits a `MediaOutput` update through the provider's emitter.
+ ///
+ /// Note that at times, GSMTC can have a valid session, but return empty
+ /// string for all media properties.
+ fn emit_output(&self) {
+ let current_session = self
+ .current_session_id
+ .as_ref()
+ .and_then(|id| self.session_states.get(id))
+ .map(|state| state.output.clone());
+
+ let all_sessions = self
+ .session_states
+ .values()
+ .map(|state| state.output.clone())
+ .collect();
+
+ self.common.emitter.emit_output(Ok(MediaOutput {
+ current_session,
+ all_sessions,
+ }));
+ }
+
+ /// Creates a `MediaSession` from a Windows media session.
+ fn to_media_session_output(
session: &GsmtcSession,
- emitter: &ProviderEmitter,
- ) -> windows::core::Result {
- debug!("Adding session listeners.");
+ session_id: &str,
+ ) -> anyhow::Result {
+ let mut session_output = MediaSession::default();
- let media_properties_changed_handler = {
- let emitter = emitter.clone();
+ session_output.session_id = session_id.to_string();
+ Self::update_media_properties(&mut session_output, &session)?;
+ Self::update_timeline_properties(&mut session_output, &session)?;
+ Self::update_playback_info(&mut session_output, &session)?;
- TypedEventHandler::new(move |session: &Option, _| {
- debug!("Media properties changed event triggered.");
+ Ok(session_output)
+ }
- if let Some(session) = session {
- Self::emit_media_info(Some(session), &emitter);
- }
+ /// Updates media metadata properties in a `MediaSession`.
+ fn update_media_properties(
+ session_output: &mut MediaSession,
+ session: &GsmtcSession,
+ ) -> anyhow::Result<()> {
+ let properties = session.TryGetMediaPropertiesAsync()?.get()?;
- Ok(())
- })
- };
+ let title = properties.Title()?.to_string();
+ let artist = properties.Artist()?.to_string();
+ let album_title = properties.AlbumTitle()?.to_string();
+ let album_artist = properties.AlbumArtist()?.to_string();
- let playback_info_changed_handler = {
- let emitter = emitter.clone();
+ session_output.title = (!title.is_empty()).then_some(title);
+ session_output.artist = (!artist.is_empty()).then_some(artist);
+ session_output.album_title =
+ (!album_title.is_empty()).then_some(album_title);
+ session_output.album_artist =
+ (!album_artist.is_empty()).then_some(album_artist);
+ session_output.track_number = properties.TrackNumber()? as u32;
- TypedEventHandler::new(move |session: &Option, _| {
- debug!("Playback info changed event triggered.");
+ Ok(())
+ }
- if let Some(session) = session {
- Self::emit_media_info(Some(session), &emitter);
- }
+ /// Updates timeline properties (position/duration) in a `MediaSession`.
+ fn update_timeline_properties(
+ session_output: &mut MediaSession,
+ session: &GsmtcSession,
+ ) -> anyhow::Result<()> {
+ let properties = session.GetTimelineProperties()?;
- Ok(())
- })
- };
+ session_output.start_time =
+ properties.StartTime()?.Duration as u64 / 10_000_000;
+ session_output.end_time =
+ properties.EndTime()?.Duration as u64 / 10_000_000;
+ session_output.position =
+ properties.Position()?.Duration as u64 / 10_000_000;
- let timeline_properties_changed_handler = {
- let emitter = emitter.clone();
+ Ok(())
+ }
- TypedEventHandler::new(move |session: &Option, _| {
- debug!("Timeline properties changed event triggered.");
+ /// Updates playback info in a `MediaSession`.
+ fn update_playback_info(
+ session_output: &mut MediaSession,
+ session: &GsmtcSession,
+ ) -> anyhow::Result<()> {
+ let info = session.GetPlaybackInfo()?;
- if let Some(session) = session {
- Self::emit_media_info(Some(session), &emitter);
- }
+ session_output.is_playing =
+ info.PlaybackStatus()? == GsmtcPlaybackStatus::Playing;
- Ok(())
- })
- };
+ Ok(())
+ }
+}
- Ok(EventTokens {
- playback_info_changed_token: session
- .PlaybackInfoChanged(&playback_info_changed_handler)?,
- media_properties_changed_token: session
- .MediaPropertiesChanged(&media_properties_changed_handler)?,
- timeline_properties_changed_token: session
- .TimelinePropertiesChanged(&timeline_properties_changed_handler)?,
- })
+impl Drop for MediaProvider {
+ fn drop(&mut self) {
+ for (_, session_state) in &self.session_states {
+ Self::remove_session_listeners(
+ &session_state.session,
+ &session_state.tokens,
+ );
+ }
}
}
-#[async_trait]
impl Provider for MediaProvider {
fn runtime_type(&self) -> RuntimeType {
RuntimeType::Sync
diff --git a/packages/desktop/src/providers/provider_function.rs b/packages/desktop/src/providers/provider_function.rs
index 8a6378c8..f95f88ad 100644
--- a/packages/desktop/src/providers/provider_function.rs
+++ b/packages/desktop/src/providers/provider_function.rs
@@ -1,15 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", content = "function", rename_all = "snake_case")]
pub enum ProviderFunction {
Media(MediaFunction),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "name", content = "args", rename_all = "snake_case")]
pub enum MediaFunction {
- PlayPause,
- Next,
- Previous,
+ Play(MediaControlArgs),
+ Pause(MediaControlArgs),
+ TogglePlayPause(MediaControlArgs),
+ Next(MediaControlArgs),
+ Previous(MediaControlArgs),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MediaControlArgs {
+ pub session_id: Option,
}
pub type ProviderFunctionResult = Result;
diff --git a/packages/desktop/src/providers/provider_manager.rs b/packages/desktop/src/providers/provider_manager.rs
index 67aa541d..e742e007 100644
--- a/packages/desktop/src/providers/provider_manager.rs
+++ b/packages/desktop/src/providers/provider_manager.rs
@@ -286,6 +286,11 @@ impl ProviderManager {
config_hash: String,
function: ProviderFunction,
) -> anyhow::Result {
+ info!(
+ "Calling provider function: {:?} for: {}",
+ function, config_hash
+ );
+
let provider_refs = self.provider_refs.lock().await;
let provider_ref = provider_refs
.get(&config_hash)