Skip to main content

tauri_plugin_media_session/
lib.rs

1use tauri::{
2    plugin::{Builder, TauriPlugin},
3    Manager, Runtime,
4};
5
6#[cfg(mobile)]
7use serde::Serialize;
8
9#[cfg(target_os = "android")]
10const PLUGIN_IDENTIFIER: &str = "app.tauri.mediasession";
11
12/// Media playback state.
13///
14/// All fields are optional — omitted fields preserve their previous values
15/// on the native side (merge semantics).
16#[cfg(mobile)]
17#[derive(Debug, Default, Clone, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct MediaState {
20    /// Track title.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub title: Option<String>,
23    /// Artist name.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub artist: Option<String>,
26    /// Album name.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub album: Option<String>,
29    /// Base64-encoded image (JPEG/PNG, no `data:` prefix).
30    /// Pass an empty string to clear artwork.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub artwork: Option<String>,
33    /// URL to an image (JPEG/PNG). Downloaded natively (no CORS).
34    /// Use this when the image is on a CDN or external server.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub artwork_url: Option<String>,
37    /// Track duration in seconds.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub duration: Option<f64>,
40    /// Current playback position in seconds.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub position: Option<f64>,
43    /// Playback speed multiplier (default: 1.0).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub playback_speed: Option<f64>,
46    /// Whether media is currently playing.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub is_playing: Option<bool>,
49    /// Whether the "previous track" action is available.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub can_prev: Option<bool>,
52    /// Whether the "next track" action is available.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub can_next: Option<bool>,
55    /// Whether seeking is available (default: true).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub can_seek: Option<bool>,
58}
59
60/// Lightweight timeline update (position, duration, speed only).
61///
62/// Skips notification rebuild — ideal for frequent position syncs.
63#[cfg(mobile)]
64#[derive(Debug, Default, Clone, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct TimelineUpdate {
67    /// Current playback position in seconds.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub position: Option<f64>,
70    /// Track duration in seconds.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub duration: Option<f64>,
73    /// Playback speed multiplier.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub playback_speed: Option<f64>,
76}
77
78/// Handle to the native media session.
79#[cfg(mobile)]
80pub struct MediaSession<R: Runtime>(tauri::plugin::PluginHandle<R>);
81
82#[cfg(mobile)]
83impl<R: Runtime> MediaSession<R> {
84    /// Update the media session state and notification.
85    ///
86    /// Auto-initializes the session on first call.
87    /// Only include the fields that changed — previous values are preserved.
88    pub fn update_state(&self, state: MediaState) -> Result<(), String> {
89        self.0
90            .run_mobile_plugin("updateState", state)
91            .map_err(|e| format!("{e}"))
92    }
93
94    /// Lightweight timeline update — only touches `PlaybackState`, skips notification rebuild.
95    ///
96    /// Use this for frequent position syncs during playback.
97    /// The session must already be initialized via [`update_state`](Self::update_state).
98    pub fn update_timeline(&self, timeline: TimelineUpdate) -> Result<(), String> {
99        self.0
100            .run_mobile_plugin("updateTimeline", timeline)
101            .map_err(|e| format!("{e}"))
102    }
103
104    /// Clear the media session, dismiss the notification, and release resources.
105    pub fn clear(&self) -> Result<(), String> {
106        self.0
107            .run_mobile_plugin::<()>("clear", ())
108            .map_err(|e| format!("{e}"))
109    }
110
111    /// Pre-initialize the session and request notification permissions.
112    ///
113    /// Optional — [`update_state`](Self::update_state) auto-initializes when needed.
114    pub fn initialize(&self) -> Result<(), String> {
115        self.0
116            .run_mobile_plugin::<()>("initialize", ())
117            .map_err(|e| format!("{e}"))
118    }
119}
120
121/// Extension trait for accessing the media session from any Tauri manager.
122#[cfg(mobile)]
123pub trait MediaSessionExt<R: Runtime> {
124    fn media_session(&self) -> &MediaSession<R>;
125}
126
127#[cfg(mobile)]
128impl<R: Runtime, T: Manager<R>> MediaSessionExt<R> for T {
129    fn media_session(&self) -> &MediaSession<R> {
130        self.state::<MediaSession<R>>().inner()
131    }
132}
133
134/// Initialize the media-session plugin.
135///
136/// On non-mobile platforms this registers a no-op plugin so that
137/// cross-platform apps compile without `cfg` gates around the plugin call.
138pub fn init<R: Runtime>() -> TauriPlugin<R> {
139    Builder::new("media-session")
140        .setup(|_app, _api| {
141            #[cfg(target_os = "android")]
142            {
143                let handle =
144                    _api.register_android_plugin(PLUGIN_IDENTIFIER, "MediaSessionPlugin")?;
145                _app.manage(MediaSession(handle));
146            }
147            Ok(())
148        })
149        .build()
150}