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() + } +}