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:
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>
|
||||
+315
@@ -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
|
||||
}
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user