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:
@@ -31,6 +31,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user