The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
110 lines
3.4 KiB
Rust
110 lines
3.4 KiB
Rust
//! 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<String>,
|
|
pub artist: Option<String>,
|
|
pub album: Option<String>,
|
|
#[serde(default)]
|
|
pub playing: bool,
|
|
#[serde(default)]
|
|
pub position: f64,
|
|
pub duration: Option<f64>,
|
|
pub art_key: Option<String>,
|
|
pub art: Option<String>,
|
|
#[serde(default = "yes")]
|
|
pub has_next: bool,
|
|
#[serde(default = "yes")]
|
|
pub has_prev: bool,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
#[cfg(mobile)]
|
|
pub struct MediaSession<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
|
|
|
#[tauri::command]
|
|
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: NowPlaying) -> Result<(), String> {
|
|
#[cfg(mobile)]
|
|
{
|
|
use tauri::Manager;
|
|
let session = app.state::<MediaSession<R>>();
|
|
session
|
|
.0
|
|
.run_mobile_plugin::<serde_json::Value>("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<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
|
#[cfg(mobile)]
|
|
{
|
|
use tauri::Manager;
|
|
let session = app.state::<MediaSession<R>>();
|
|
session
|
|
.0
|
|
.run_mobile_plugin::<serde_json::Value>("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<R: Runtime>() -> TauriPlugin<R> {
|
|
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()
|
|
}
|