feat(android): MinstrelPlayerService — Media3 MediaSessionService (M8 phase 6.2)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:26:47 -04:00
parent 327ecb6757
commit 85b8452b78
2 changed files with 72 additions and 1 deletions
+8 -1
View File
@@ -31,6 +31,13 @@
</intent-filter>
</activity>
<!-- Media3 MediaSessionService is registered in Phase 6 (Task 6.2). -->
<service
android:name=".player.MinstrelPlayerService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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()
}
}