diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index cf423bb28..13ef39dde 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -106,4 +106,8 @@ dependencies { implementation("androidx.media3:media3-exoplayer-dash:1.4.1") implementation("androidx.media3:media3-exoplayer-hls:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") + // Media3 session — MediaSessionService for background audio + lock-screen controls. + implementation("androidx.media3:media3-session:1.4.1") + // Guava ListenableFuture support for awaiting MediaController connect. + implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") } diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index 165683b38..b0a82e1ac 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -3,6 +3,13 @@ + + + + + + + + + + + + + + diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt new file mode 100644 index 000000000..4d2f770a2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase M-2: Media3 MediaSessionService hosting a single ExoPlayer. Running + * as a foreground service means audio keeps playing after the user leaves + * the Player screen (Cobb's feedback: "no background player"). Also gives + * us lock-screen controls and a media-styled notification for free. + */ + +package com.sulkta.straw.feature.player + +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +@UnstableApi +class PlaybackService : MediaSessionService() { + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = ExoPlayer.Builder(this).build() + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession( + controllerInfo: MediaSession.ControllerInfo, + ): MediaSession? = mediaSession + + override fun onTaskRemoved(rootIntent: android.content.Intent?) { + // If user swipes Straw out of recents while audio is playing, keep + // playing. Stop only when player has nothing queued. + val player = mediaSession?.player + if (player == null || !player.playWhenReady || player.mediaItemCount == 0) { + stopSelf() + } + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index e65006436..01dc37a56 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -41,11 +41,14 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.MergingMediaSource @@ -68,11 +71,33 @@ fun PlayerScreen( LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } val exoPlayer = remember { - ExoPlayer.Builder(context).build() + ExoPlayer.Builder(context) + .setAudioAttributes( + // Tell the system we're playing media so audio focus + + // ducking + Bluetooth routing work, and notifications can + // sit alongside other media apps. + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + /* handleAudioFocus = */ true, + ) + .build() + } + + // Wrap the player in a MediaSession so the OS gets lock-screen + + // notification media controls while this Activity is alive. Full + // background-audio-after-Activity-kill is M-3 (MediaSessionService + + // MediaController refactor). + val mediaSession = remember { + MediaSession.Builder(context, exoPlayer).build() } DisposableEffect(Unit) { - onDispose { exoPlayer.release() } + onDispose { + mediaSession.release() + exoPlayer.release() + } } // AUD-MED: pause playback when app goes to background. Without this,