android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,510 @@
|
||||
package com.soulstream.mediasession
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* What is playing, shared between the plugin (which hears the WebView) and
|
||||
* the service (which owns the session). The service is BOUND on the first
|
||||
* update and refreshed on every one after.
|
||||
*
|
||||
* Bound, not started, and that is half the safety story of this file.
|
||||
* `MediaSessionService.onStartCommand` promotes the service for a media
|
||||
* action, a custom action or media3's own self-start intent — which it
|
||||
* recognises by a uid extra of its own making — and returns START_STICKY
|
||||
* without calling `startForeground()` for anything else. A plain
|
||||
* `Intent(ctx, MediaService::class.java)` is anything else. So a
|
||||
* `startForegroundService` here would arm Android's ten-second timer and
|
||||
* satisfy nothing, which is the 2026-09-13 bug with a new coat on. media3
|
||||
* promotes ITSELF, once there is something playing to promote for:
|
||||
* `MediaNotificationManager.startForeground()` does the
|
||||
* `startForegroundService` and the `setForegroundServiceNotification` in the
|
||||
* same call. `bindService` merely creates the service, is not subject to the
|
||||
* Android 8+/12+ background-start restriction, and never arms the timer.
|
||||
*
|
||||
* The other half is [MediaService.onStartCommand], because the SYSTEM starts
|
||||
* this service in the foreground on a button press whether media3 then wants
|
||||
* it there or not.
|
||||
*/
|
||||
object MediaHub {
|
||||
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
||||
@Volatile var state: NowPlayingArgs? = null
|
||||
/** the cover as ENCODED bytes — media3 decodes them itself */
|
||||
@Volatile var artBytes: ByteArray? = null
|
||||
@Volatile var artKey: String? = null
|
||||
@Volatile var service: MediaService? = null
|
||||
@Volatile var player: SoulStreamPlayer? = null
|
||||
/** the app's own icon, encoded once — see appIcon() */
|
||||
@Volatile private var icon: ByteArray? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var bound = false
|
||||
private val conn = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {}
|
||||
/* the service died; the binding survives and the system rebuilds it */
|
||||
override fun onServiceDisconnected(name: ComponentName?) {}
|
||||
}
|
||||
|
||||
fun update(ctx: Context, np: NowPlayingArgs) {
|
||||
if (np.artKey != artKey) {
|
||||
artKey = np.artKey
|
||||
artBytes = null
|
||||
}
|
||||
np.art?.let { b64 ->
|
||||
try {
|
||||
artBytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
} catch (_: Exception) {
|
||||
/* an unreadable cover is no cover */
|
||||
}
|
||||
}
|
||||
state = np
|
||||
bind(ctx)
|
||||
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
|
||||
verifyApplicationThread(), a Tauri @Command is not promised to run
|
||||
on the main thread, and the IllegalStateException that follows
|
||||
would be swallowed by media.svelte.js's own `.catch(() => {})` —
|
||||
the card would just quietly stop updating, with nothing in any log. */
|
||||
mainHandler.post {
|
||||
service?.syncFocus(np.playing)
|
||||
player?.let {
|
||||
it.settle()
|
||||
it.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The bar emptied. The playlist goes empty, which is how media3 is told
|
||||
* to take the card down (`shouldShowNotification` refuses an empty
|
||||
* timeline) and to step out of the foreground. The service is left
|
||||
* alive and the session left registered ON PURPOSE: a headset button
|
||||
* should still reach us with nothing on the bar, and re-creating the
|
||||
* service is the expensive half. Audio focus IS given back, which the
|
||||
* old service did by dying. */
|
||||
fun clear(ctx: Context) {
|
||||
state = null
|
||||
artBytes = null
|
||||
artKey = null
|
||||
mainHandler.post {
|
||||
service?.dropFocus()
|
||||
player?.let {
|
||||
it.settle()
|
||||
it.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* two updates can arrive on two binder threads; one binding is enough */
|
||||
@Synchronized
|
||||
private fun bind(ctx: Context) {
|
||||
if (bound) return
|
||||
try {
|
||||
/* the action matters: MediaSessionService.onBind answers only for
|
||||
SERVICE_INTERFACE and returns null for a bare intent, and
|
||||
BIND_AUTO_CREATE is what creates the service at all */
|
||||
val intent = Intent(ctx, MediaService::class.java)
|
||||
.setAction(MediaSessionService.SERVICE_INTERFACE)
|
||||
bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
|
||||
if (!bound) {
|
||||
android.util.Log.w("SoulStream", "media service would not bind")
|
||||
try { ctx.unbindService(conn) } catch (_: Exception) {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "media service could not bind: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own icon, encoded ONCE and kept, as the card's artwork when
|
||||
* there is no cover yet. `artBytes` is cleared the moment `artKey`
|
||||
* changes and only refilled when the frontend has finished decoding a
|
||||
* cover into bytes — and a track with no cover never refills it at all —
|
||||
* so every track change has a real window with no artwork and the lock
|
||||
* screen shows a grey hole. This is what stands in it.
|
||||
*
|
||||
* PNG bytes rather than a Bitmap since the media3 migration:
|
||||
* `MediaMetadata.setArtworkData` wants encoded bytes and media3 decodes
|
||||
* them itself, which is one BitmapFactory call this plugin no longer
|
||||
* makes.
|
||||
*
|
||||
* Through the PackageManager rather than a resource id on purpose: this
|
||||
* is a library module and its own R has no `ic_launcher`, so from in here
|
||||
* the app's icon is only reachable this way.
|
||||
*
|
||||
* Drawn at the tile's own bounds, which is the whole trick. On API 26+
|
||||
* what comes back is an AdaptiveIconDrawable, and an adaptive icon's
|
||||
* BOUNDS are the 72 units the launcher shows — not the 108 its layers
|
||||
* are drawn in. The drawable scales its own layers up by 3/2 and masks
|
||||
* them inside whatever box it is handed, which is why its intrinsic
|
||||
* width is two thirds of the layer PNG. So setBounds(0, 0, 512, 512) IS
|
||||
* the launcher's view; a box set outside the tile to "reveal the 18%
|
||||
* bleed" does the opposite and crops into the safe zone. Every other
|
||||
* kind of drawable simply fills the tile.
|
||||
*
|
||||
* Encoded on the first ask and never again — getState() runs on every
|
||||
* invalidation, and this must not. A failure caches nothing and answers
|
||||
* null, which is the card exactly as it was before this existed. */
|
||||
fun appIcon(ctx: Context): ByteArray? {
|
||||
val cached = icon
|
||||
if (cached != null) return cached
|
||||
return try {
|
||||
val drawable = ctx.packageManager.getApplicationIcon(ctx.packageName)
|
||||
val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
|
||||
drawable.setBounds(0, 0, 512, 512)
|
||||
drawable.draw(Canvas(bitmap))
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
bitmap.recycle()
|
||||
val bytes = out.toByteArray()
|
||||
icon = bytes
|
||||
bytes
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "app icon could not be encoded: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun act(action: String, position: Double = -1.0) {
|
||||
onAction?.invoke(action, position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
|
||||
* card on the lock screen and in the shade, 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.
|
||||
*
|
||||
* media3 posts and owns the notification now, and promotes and demotes the
|
||||
* service around it — there is no `buildNotification` and no
|
||||
* `MediaButtonReceiver` in this file any more, and the manifest's
|
||||
* `androidx.media3.session.MediaSessionService` filter is what routes a
|
||||
* headset button here while the process is alive.
|
||||
*
|
||||
* Focus and becoming-noisy are still hand-rolled, and that is not an
|
||||
* oversight. `setAudioAttributes(attrs, handleAudioFocus = true)` and
|
||||
* `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a custom
|
||||
* `Player` the first lands in `handleSetAudioAttributes`, whose default body
|
||||
* throws, and the second does not exist. Deleting these blocks would lose
|
||||
* "another app takes the output → we pause" and "headphones out → we pause"
|
||||
* with nothing failing anywhere and no walk able to catch it.
|
||||
*/
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
class MediaService : MediaSessionService() {
|
||||
companion object {
|
||||
/** Literally "playback", and load-bearing twice over: the phone
|
||||
* walk's `media-card` check finds the card by this channel id, and
|
||||
* `Util.ensureNotificationChannel` is a no-op when the channel
|
||||
* already exists — so the channel on Kyle's phone survives the
|
||||
* migration rather than being replaced. */
|
||||
const val CHANNEL = "playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
/** The stand-in of [holdForeground], on an id of its own — 1 is the
|
||||
* card, 2 is the transfer notice (`TransferHub.NOTIFICATION_ID`;
|
||||
* notification ids are per-PACKAGE and these two plugins share
|
||||
* one), 20938 is media3's own shutdown notification. */
|
||||
const val HOLD_NOTIFICATION_ID = 3
|
||||
}
|
||||
|
||||
private var session: MediaSession? = null
|
||||
private var player: SoulStreamPlayer? = null
|
||||
private var focus: AudioFocusRequest? = null
|
||||
private var pausedByFocus = false
|
||||
private var noisyRegistered = 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 onCreate() {
|
||||
super.onCreate()
|
||||
/* ours first. media3 would create the channel itself, at
|
||||
IMPORTANCE_LOW and nothing else — no lockscreenVisibility, and
|
||||
setShowBadge only below API 28 — so on a fresh install the card
|
||||
would be hidden behind "sensitive notifications". ensureNotification
|
||||
Channel leaves an existing channel alone, so getting in first is
|
||||
the whole fix. */
|
||||
createChannel()
|
||||
|
||||
val p = SoulStreamPlayer(applicationContext)
|
||||
player = p
|
||||
MediaHub.player = p
|
||||
|
||||
/* BEFORE addSession, and the order is real: addSession spins up the
|
||||
internal notification controller that reads this provider, and both
|
||||
calls post to the same main handler (inline, from here) in order. */
|
||||
val provider = DefaultMediaNotificationProvider.Builder(this)
|
||||
.setChannelId(CHANNEL)
|
||||
.setChannelName(R.string.ss_playback_channel)
|
||||
.setNotificationId(NOTIFICATION_ID)
|
||||
.build()
|
||||
provider.setSmallIcon(R.drawable.ic_soulstream_note)
|
||||
setMediaNotificationProvider(provider)
|
||||
|
||||
val builder = MediaSession.Builder(this, p).setId("SoulStream")
|
||||
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (launch != null) {
|
||||
builder.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
this, 0, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
val s = builder.build()
|
||||
session = s
|
||||
addSession(s)
|
||||
|
||||
/* 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
|
||||
/* The update that MADE this service is already sitting in MediaHub,
|
||||
and its own post to the main looper found `service` still null —
|
||||
bindService creates the service a round trip later. Without this
|
||||
line the focus request waits for whatever update happens to come
|
||||
next (a status change, or the thirty-second top-up), and the first
|
||||
track of a cold start plays with no focus held. */
|
||||
if (MediaHub.state?.playing == true) requestFocus()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
/* ── the foreground contract media3 does not keep for us ──────────────
|
||||
*
|
||||
* Nothing in this plugin starts the service — and the SYSTEM starts it
|
||||
* in the foreground anyway, on every media KEY. `MediaSessionLegacyStub`
|
||||
* hands the framework the media-button PendingIntent, and because the
|
||||
* receiver it resolves is a SERVICE it builds that intent with
|
||||
* `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
|
||||
* every key. It resolves a service because there is no BroadcastReceiver
|
||||
* any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
|
||||
* receiver first, finds none, and falls to the manifest's
|
||||
* `androidx.media3.session.MediaSessionService` filter. So a headset
|
||||
* key, a lock-screen button and anything else the framework routes to
|
||||
* the session arms Android's ten-second `startForeground()` timer — the
|
||||
* same timer that took a Save down with the process on 2026-09-13.
|
||||
*
|
||||
* The CARD's own buttons are milder: `DefaultActionFactory` sets
|
||||
* `setStartAsForegroundService(!player.playWhenReady)`, but
|
||||
* `PlaybackPendingIntentBuilder.build()` only honours it for
|
||||
* COMMAND_PLAY_PAUSE, so the paused card's play button is a foreground
|
||||
* start and every other card button is a plain `getService`. Play is the
|
||||
* one press that always ends in media3 promoting anyway. The keys are
|
||||
* the problem.
|
||||
*
|
||||
* media3 promotes only for a session it calls user-engaged:
|
||||
* playWhenReady AND STATE_READY/BUFFERING, or inside the ten-minute
|
||||
* tail after that stopped being true
|
||||
* (`MediaNotificationManager.shouldRunInForeground`). Its own
|
||||
* `stopSelfSafely()` — which promotes, demotes and stops — covers the
|
||||
* intents it cannot resolve: a null session from `onGetSession` (ours
|
||||
* never is), a custom action it cannot name (we declare none), and a
|
||||
* stale self-start intent from a destroyed instance. Three presses
|
||||
* qualify for none of it, and each would take the app down about ten
|
||||
* seconds later:
|
||||
*
|
||||
* · a key into a process the system built FOR that key — no state,
|
||||
* no WebView, nothing to promote for;
|
||||
* · a key on a card paused longer than ten minutes, where the tail
|
||||
* has run out and the press does not start playback (previous,
|
||||
* which the app's `prev()` answers by seeking to zero);
|
||||
* · any key at all with the bar cleared — which is exactly the case
|
||||
* `MediaHub.clear` deliberately keeps the session alive for.
|
||||
*
|
||||
* So promote FIRST, the way this service always did, and let media3
|
||||
* step back out on its own schedule. Two ways, and the split is what
|
||||
* keeps this from fighting the library:
|
||||
*
|
||||
* · there is something on the bar → media3's OWN path, with media3's
|
||||
* own card, so its `startedInForeground` bookkeeping stays true and
|
||||
* the shade never sees a stand-in. Harmless when it is already in
|
||||
* the foreground: the same notification is posted again.
|
||||
* · there is not → media3 cannot be in the foreground (an empty
|
||||
* timeline fails `shouldShowNotification`, which removes the
|
||||
* notification and demotes), so a plain notification on an id of
|
||||
* its own, posted and withdrawn in the same breath, disturbs
|
||||
* nothing. The contract is satisfied the moment `startForeground()`
|
||||
* lands.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
/* BEFORE super, deliberately: super dispatches the button, and it
|
||||
may reach media3's `stopSelfSafely()`, which promotes, demotes and
|
||||
stops. Promoting after that would put a service already on its way
|
||||
out back into the foreground. A null intent is the system
|
||||
re-creating a START_STICKY service — not a foreground start, and
|
||||
it needs nothing. */
|
||||
if (intent != null) holdForeground()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun holdForeground() {
|
||||
val s = session
|
||||
if (s != null && MediaHub.state != null) {
|
||||
try {
|
||||
/* the boolean is startInForegroundRequired */
|
||||
onUpdateNotification(s, true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "notification update refused: $e")
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ServiceCompat.startForeground(
|
||||
this, HOLD_NOTIFICATION_ID, holdNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
||||
)
|
||||
} else {
|
||||
startForeground(HOLD_NOTIFICATION_ID, holdNotification())
|
||||
}
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "could not hold the foreground: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The stand-in, up for as long as two binder calls take. Plain on
|
||||
* purpose: it is never the card, it carries no transport, and the only
|
||||
* thing it has to do is exist. */
|
||||
private fun holdNotification(): Notification {
|
||||
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val open = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
this, 0, it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_soulstream_note)
|
||||
.setContentTitle("SoulStream")
|
||||
.setContentIntent(open)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (noisyRegistered) {
|
||||
try { unregisterReceiver(noisy) } catch (_: Exception) {}
|
||||
noisyRegistered = false
|
||||
}
|
||||
abandonFocus()
|
||||
/* the player first, then the session — media3's own order. `release()`
|
||||
is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
|
||||
main thread (onDestroy runs there); markReleased() is our own latch
|
||||
so a refresh() that slipped through MediaHub does nothing. */
|
||||
player?.release()
|
||||
player?.markReleased()
|
||||
session?.release()
|
||||
session = null
|
||||
player = null
|
||||
MediaHub.player = null
|
||||
MediaHub.service = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Focus is asked for when something starts playing and kept until the
|
||||
* bar empties or the service dies — called from MediaHub's own post to
|
||||
* the main looper, so it rides the same thread everything else here
|
||||
* does. */
|
||||
fun syncFocus(playing: Boolean) {
|
||||
if (playing) requestFocus()
|
||||
}
|
||||
|
||||
/** The bar emptied. The old service gave focus back by dying; this one
|
||||
* outlives the music, so it gives it back by hand. */
|
||||
fun dropFocus() {
|
||||
pausedByFocus = false
|
||||
abandonFocus()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user