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,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>
|
||||
Reference in new issue
Block a user