From 85b8452b781d2260d43cb82976ae8f5a5d807ef0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 12:26:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20MinstrelPlayerService=20?= =?UTF-8?q?=E2=80=94=20Media3=20MediaSessionService=20(M8=20phase=206.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual replacement for everything audio_service plugin wrapped. Media3 owns the foreground-service lifecycle, MediaSession token, notification card, lock-screen surface, Bluetooth/AVRCP routing, Pixel Watch tile, and Android Auto adapter natively — no plugin layer between us and the platform. Service shape (~25 LOC): - @AndroidEntryPoint MediaSessionService - @Inject PlayerFactory builds ExoPlayer in onCreate - onGetSession returns the live MediaSession to any binding controller (system UI, Wear OS companion, MediaController3 clients) - onTaskRemoved keeps playing while audio is active (standard media-app behavior); otherwise stopSelf so notification clears - onDestroy releases session + player Compare with flutter_client/lib/player/audio_handler.dart's 1000+ LOC across MinstrelAudioHandler + the soft-teardown / stall-watchdog / recovery machinery. Media3 owns most of that natively; we'll get to the small portions we still need (queue management, position reporting facade) in 6.3. Manifest registration: foregroundServiceType="mediaPlayback" + MediaSessionService intent-filter. MediaButtonReceiver is registered by the Media3 library; no manual receiver class needed (Flutter's manifest had to declare audio_service's receiver explicitly). Co-Authored-By: Claude Opus 4.7 (1M context) --- android/app/src/main/AndroidManifest.xml | 9 ++- .../minstrel/player/MinstrelPlayerService.kt | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 39c2080f..7eaeb5d2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,13 @@ - + + + + + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt new file mode 100644 index 00000000..fba943f6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -0,0 +1,64 @@ +package com.fabledsword.minstrel.player + +import android.content.Intent +import androidx.media3.common.Player +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * The app's foreground media-playback service. Replaces what the Flutter + * audio_service plugin wrapped — Media3 owns the foreground lifecycle, + * the MediaSession, the notification card, lock-screen surface, BT/AVRCP + * routing, Pixel Watch tile, and Android Auto adapter natively. + * + * Manifest declares this with `foregroundServiceType="mediaPlayback"` + * plus the standard `MediaSessionService` intent-filter — Media3's + * MediaButtonReceiver is registered automatically by the library, no + * manual receiver class needed. + * + * Lifecycle: + * - onCreate: build the ExoPlayer + MediaSession once. + * - onGetSession: return the live session to any binding controller + * (system UI media card, Wear OS companion, MediaController3 clients). + * - onTaskRemoved: if the user swipes the app away, keep playing when + * audio is active (standard media-app behavior — music shouldn't + * die because the app left recents); otherwise stop the service so + * the lingering notification clears. + * - onDestroy: release the session + player. + */ +@AndroidEntryPoint +class MinstrelPlayerService : MediaSessionService() { + + @Inject lateinit var playerFactory: PlayerFactory + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = playerFactory.build() + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = + mediaSession + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent) + val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED + if (!activelyPlaying) { + stopSelf() + } + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + super.onDestroy() + } +}