//! The media session on the phone. //! //! Android shows a playing app on the lock screen and in the shade through a //! `MediaSession` carried by a foreground service, and it is that service //! that keeps the process alive — and un-frozen — once the app is in the //! background. The audio itself stays where it is, on the Rust audio thread; //! the Kotlin side only mirrors what is playing and hands the card's //! buttons, the headset's buttons and audio-focus changes back as events. //! //! Two commands, both fed by the phone UI (src/mobile/media.svelte.js): //! `update` with the track on the bar, `clear` when the bar empties. The //! session's actions arrive as the plugin event `action` //! (`addPluginListener("media-session", "action", …)`). //! //! On the desktop the plugin is registered too, so the permission set //! resolves on every platform, but its commands answer "not here": the //! desktop has its own card (src-tauri/src/smtc.rs). use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, Runtime, }; /// What the card shows. `art` carries the cover's bytes (base64) only when /// `art_key` changed since the last update — the card keeps the bitmap. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct NowPlaying { pub title: Option, pub artist: Option, pub album: Option, #[serde(default)] pub playing: bool, #[serde(default)] pub position: f64, pub duration: Option, pub art_key: Option, pub art: Option, #[serde(default = "yes")] pub has_next: bool, #[serde(default = "yes")] pub has_prev: bool, /// The queue around what is playing: the current item and up to ten /// either side, rebuilt on every update. /// /// Round forty-eight is where this BECAME the card. `SoulStreamPlayer` /// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and /// an index into one, and this window and `queue_index` are exactly that /// — so what the lock screen shows is built out of these fields rather /// than beside them. /// /// A CAP, not a length: a two-thousand-track queue crosses the bridge as /// twenty-one entries exactly like a twenty-one-track one does. #[serde(default)] pub queue: Vec, /// Where the current item sits IN `queue` — not in the whole queue. /// `None` when the window is empty. pub queue_index: Option, /// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData` /// uid is built from it, so an entry keeps its identity as the window /// slides past it and a track change is ONE media-item transition /// rather than twenty-one re-labelled rows; it is also half of the /// mapping Android Auto needs to turn a tapped queue row back into /// `playQueueAt()`. `None` when the window is empty. pub queue_base: Option, /// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3 /// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side /// maps across rather than casting, which would turn repeat-all into /// repeat-one. It crosses at all so the player's state is not a lie to /// whatever reads it — the system's own media controls now, Android /// Auto later — and so media3's view of "is there a next item" agrees /// with the `has_next` the card's next button is advertised on. #[serde(default)] pub repeat: u8, } fn yes() -> bool { true } /// One entry of the queue window. Deliberately flat and small: this crosses /// two hops of IPC on every update — the WebView to Rust, Rust to Kotlin — /// so it carries what a media3 `MediaItem` is built from and nothing else. /// `art_key` is the same kind of key `NowPlaying::art_key` is, the cover's /// path, so whatever decodes a bitmap for the card can key the window's /// covers off the same thing; no bytes ride along here. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct QueueItem { /// The track's own id as a string — a library track's is a number, a /// catalog track's is `cat:`, a playlist row's is `pl`. NOT a /// unique key: one queue may hold the same track twice, and a window /// entry is identified by where it sits, not by this. pub id: Option, pub title: Option, pub artist: Option, pub album: Option, pub duration: Option, pub art_key: Option, } #[cfg(mobile)] pub struct MediaSession(tauri::plugin::PluginHandle); #[tauri::command] fn update(app: tauri::AppHandle, state: NowPlaying) -> Result<(), String> { #[cfg(mobile)] { use tauri::Manager; let session = app.state::>(); session .0 .run_mobile_plugin::("update", state) .map(|_| ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = (app, state); Err("the media session is a phone feature".into()) } } #[tauri::command] fn clear(app: tauri::AppHandle) -> Result<(), String> { #[cfg(mobile)] { use tauri::Manager; let session = app.state::>(); session .0 .run_mobile_plugin::("clear", ()) .map(|_| ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Err("the media session is a phone feature".into()) } } pub fn init() -> TauriPlugin { Builder::new("media-session") .invoke_handler(tauri::generate_handler![update, clear]) .setup(|app, api| { #[cfg(target_os = "android")] { use tauri::Manager; let handle = api.register_android_plugin("com.soulstream.mediasession", "MediaSessionPlugin")?; app.manage(MediaSession(handle)); } #[cfg(not(target_os = "android"))] { let _ = (app, api); } Ok(()) }) .build() }