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,