android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour

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.
This commit is contained in:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-media-session"
version = "0.1.0"
description = "SoulStream's media session on the phone: the lock-screen card, the notification's transport, audio focus, headset buttons — a foreground service that keeps playback alive in the background"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-media-session"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,28 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.mediasession"
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
// MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver
implementation("androidx.media:media:1.7.0")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the foreground service that carries the session while the app is in
the background (mediaPlayback is required from Android 14) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- the card is a notification; asked for at runtime on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name="com.soulstream.mediasession.MediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<!-- lets MediaButtonReceiver forward headset and card buttons here -->
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,315 @@
package com.soulstream.mediasession
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.IBinder
import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Base64
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media.session.MediaButtonReceiver
/**
* What is playing, shared between the plugin (which hears the WebView) and
* the service (which owns the session). The service is started on the first
* update and refreshed on every one after.
*/
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
@Volatile var art: Bitmap? = null
@Volatile var artKey: String? = null
@Volatile var service: MediaService? = null
fun update(ctx: Context, np: NowPlayingArgs) {
if (np.artKey != artKey) {
artKey = np.artKey
art = null
}
np.art?.let { b64 ->
try {
val bytes = Base64.decode(b64, Base64.DEFAULT)
art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) {
/* an unreadable cover is no cover */
}
}
state = np
val running = service
if (running == null) {
/* Android 12+ refuses a foreground start from the background; the
card simply waits for the next update from the foreground */
try {
ContextCompat.startForegroundService(ctx, Intent(ctx, MediaService::class.java))
} catch (e: Exception) {
android.util.Log.w("SoulStream", "media service could not start: $e")
}
} else {
running.refresh()
}
}
fun clear(ctx: Context) {
state = null
val running = service
if (running != null) running.stopSelf() else ctx.stopService(Intent(ctx, MediaService::class.java))
}
fun act(action: String, position: Double = -1.0) {
onAction?.invoke(action, position)
}
}
/**
* The foreground service: a MediaSession the system reads for the lock
* screen and the shade, a MediaStyle notification with the transport, audio
* focus (pause when another app takes the output, or the headphones come
* out), and the reason the process is not frozen in the background. The
* audio never passes through here.
*/
class MediaService : Service() {
companion object {
const val CHANNEL = "playback"
const val NOTIFICATION_ID = 1
}
private lateinit var session: MediaSessionCompat
private var focus: AudioFocusRequest? = null
private var pausedByFocus = false
private var noisyRegistered = false
private var inForeground = false
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> {
pausedByFocus = false
MediaHub.act("pause")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (MediaHub.state?.playing == true) {
pausedByFocus = true
MediaHub.act("pause")
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByFocus) {
pausedByFocus = false
MediaHub.act("play")
}
}
/* CAN_DUCK: the framework ducks us on its own */
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
session = MediaSessionCompat(this, "SoulStream")
session.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { MediaHub.act("play") }
override fun onPause() { MediaHub.act("pause") }
override fun onSkipToNext() { MediaHub.act("next") }
override fun onSkipToPrevious() { MediaHub.act("prev") }
override fun onStop() { MediaHub.act("stop") }
override fun onSeekTo(pos: Long) { MediaHub.act("seek", pos / 1000.0) }
})
session.isActive = true
createChannel()
/* a system broadcast reaches a non-exported receiver; Android 14 wants
the flag said out loud */
ContextCompat.registerReceiver(
this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
noisyRegistered = true
MediaHub.service = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
/* a headset or card button arrives as a MEDIA_BUTTON intent */
MediaButtonReceiver.handleIntent(session, intent)
/* Started with startForegroundService, so Android demands
startForeground() promptly WHATEVER the state — a paused restore
and an empty bar included — or it kills the whole app
(ForegroundServiceDidNotStartInTimeException: on 2026-09-13 a Save
died with the process, 4 of 14 tracks in). Promote first, with the
card there is or a plain one; refresh() may step back at once. */
if (!inForeground) promote(buildNotification(MediaHub.state ?: NowPlayingArgs()))
refresh()
return START_NOT_STICKY
}
private fun promote(notification: Notification) {
if (Build.VERSION.SDK_INT >= 29) {
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
}
inForeground = true
}
override fun onDestroy() {
if (noisyRegistered) {
try { unregisterReceiver(noisy) } catch (_: Exception) {}
noisyRegistered = false
}
abandonFocus()
session.isActive = false
session.release()
MediaHub.service = null
super.onDestroy()
}
/** Mirror MediaHub.state into the session and the notification. */
fun refresh() {
val np = MediaHub.state
if (np == null) {
if (inForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
inForeground = false
}
stopSelf()
return
}
val meta = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, np.title ?: "")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, np.artist ?: "")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, np.album ?: "")
np.duration?.let { meta.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong()) }
MediaHub.art?.let {
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it)
}
session.setMetadata(meta.build())
var actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_STOP
if (np.hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
if (np.hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
val state = PlaybackStateCompat.Builder()
.setActions(actions)
.setState(
if (np.playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
(np.position * 1000).toLong(),
if (np.playing) 1f else 0f,
SystemClock.elapsedRealtime(),
)
.build()
session.setPlaybackState(state)
if (np.playing) requestFocus()
val notification = buildNotification(np)
if (np.playing) {
promote(notification)
} else {
/* paused: the card stays, the service steps back so the system
may reclaim it and the card can be swiped away */
if (inForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
inForeground = false
}
try {
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
/* no notification permission: the session alone still serves the lock screen */
}
}
}
private fun buildNotification(np: NowPlayingArgs): Notification {
val launch = packageManager.getLaunchIntentForPackage(packageName)
val open = launch?.let {
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val prev = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)
val toggle = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)
val next = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
val stop = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP)
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_soulstream_note)
.setContentTitle(np.title ?: "SoulStream")
.setContentText(np.artist ?: "")
.setSubText(np.album)
.setLargeIcon(MediaHub.art)
.setContentIntent(open)
.setDeleteIntent(stop)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setOngoing(np.playing)
.addAction(android.R.drawable.ic_media_previous, "Previous", prev)
.addAction(
if (np.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (np.playing) "Pause" else "Play",
toggle,
)
.addAction(android.R.drawable.ic_media_next, "Next", next)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(session.sessionToken)
.setShowActionsInCompactView(0, 1, 2),
)
.build()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < 26) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL) != null) return
val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
channel.description = "What is playing"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
manager.createNotificationChannel(channel)
}
private fun requestFocus() {
if (focus != null) return
val manager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
}
private fun abandonFocus() {
val request = focus ?: return
(getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
focus = null
}
}
@@ -0,0 +1,77 @@
package com.soulstream.mediasession
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/** What the card shows — the Rust `NowPlaying`, camelCase. */
@InvokeArg
class NowPlayingArgs {
var title: String? = null
var artist: String? = null
var album: String? = null
var playing: Boolean = false
var position: Double = 0.0
var duration: Double? = null
/** identifies the cover; the bytes only come along when it changes */
var artKey: String? = null
/** the cover, base64 image bytes */
var art: String? = null
var hasNext: Boolean = true
var hasPrev: Boolean = true
}
/**
* The bridge between the WebView and the service that owns the session.
* `update` mirrors the track on the bar (and starts the service the first
* time); `clear` takes the card down. The session's buttons come back as
* the plugin event `action` with `{ action, position }`.
*/
@TauriPlugin
class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
private var askedForNotifications = false
init {
MediaHub.onAction = { action, position ->
val payload = JSObject()
payload.put("action", action)
payload.put("position", position)
trigger("action", payload)
}
}
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java)
ensureNotificationPermission()
MediaHub.update(activity.applicationContext, args)
invoke.resolve()
}
@Command
fun clear(invoke: Invoke) {
MediaHub.clear(activity.applicationContext)
invoke.resolve()
}
/** Android 13+: the card is a notification and needs the permission; asked
* once, the first time something plays. Media controls in the shade and
* on the lock screen follow the session either way. */
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
askedForNotifications = true
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) return
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4711)
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the status-bar glyph: a note, white on transparent, as the shade wants -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h4V3H12zM10,19a2,2 0,1 1,0 -4a2,2 0,0 1,0 4z" />
</vector>
@@ -0,0 +1,15 @@
// registerListener / removeListener are the mobile plugin runtime's own
// commands, the ones `addPluginListener` invokes to hear the `action`
// event. They are commands like any other to the ACL: without them here the
// listener was refused ("media-session.registerListener not allowed") and
// the card's buttons went nowhere.
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
fn main() {
// Registers the command permissions (permissions/autogenerated) and, on
// Android, hands the Gradle module under android/ to tauri-build, which
// includes it in the app project (tauri.settings.gradle).
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-clear"
description = "Enables the clear command without any pre-configured scope."
commands.allow = ["clear"]
[[permission]]
identifier = "deny-clear"
description = "Denies the clear command without any pre-configured scope."
commands.deny = ["clear"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-registerListener"
description = "Enables the registerListener command without any pre-configured scope."
commands.allow = ["registerListener"]
[[permission]]
identifier = "deny-registerListener"
description = "Denies the registerListener command without any pre-configured scope."
commands.deny = ["registerListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-removeListener"
description = "Enables the removeListener command without any pre-configured scope."
commands.allow = ["removeListener"]
[[permission]]
identifier = "deny-removeListener"
description = "Denies the removeListener command without any pre-configured scope."
commands.deny = ["removeListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update"
description = "Enables the update command without any pre-configured scope."
commands.allow = ["update"]
[[permission]]
identifier = "deny-update"
description = "Denies the update command without any pre-configured scope."
commands.deny = ["update"]
@@ -0,0 +1,127 @@
## Default Permission
The phone's media session: the lock-screen card, the notification's
transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
#### This default permission set includes the following:
- `allow-update`
- `allow-clear`
- `allow-registerListener`
- `allow-removeListener`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`media-session:allow-clear`
</td>
<td>
Enables the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-clear`
</td>
<td>
Denies the clear command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-removeListener`
</td>
<td>
Enables the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-removeListener`
</td>
<td>
Denies the removeListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:allow-update`
</td>
<td>
Enables the update command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`media-session:deny-update`
</td>
<td>
Denies the update command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,10 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The phone's media session: the lock-screen card, the notification's
transport, audio focus. `update` mirrors the track on the bar, `clear`
takes the card down, and the two listener commands are what lets the page
hear the card's buttons (`addPluginListener`).
"""
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
@@ -0,0 +1,354 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the clear command without any pre-configured scope.",
"type": "string",
"const": "allow-clear",
"markdownDescription": "Enables the clear command without any pre-configured scope."
},
{
"description": "Denies the clear command without any pre-configured scope.",
"type": "string",
"const": "deny-clear",
"markdownDescription": "Denies the clear command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
"const": "allow-registerListener",
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
},
{
"description": "Denies the registerListener command without any pre-configured scope.",
"type": "string",
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the removeListener command without any pre-configured scope.",
"type": "string",
"const": "allow-removeListener",
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
},
{
"description": "Denies the removeListener command without any pre-configured scope.",
"type": "string",
"const": "deny-removeListener",
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
},
{
"description": "Enables the update command without any pre-configured scope.",
"type": "string",
"const": "allow-update",
"markdownDescription": "Enables the update command without any pre-configured scope."
},
{
"description": "Denies the update command without any pre-configured scope.",
"type": "string",
"const": "deny-update",
"markdownDescription": "Denies the update command without any pre-configured scope."
},
{
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
"type": "string",
"const": "default",
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
}
]
}
}
}
@@ -0,0 +1,109 @@
//! 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()
}