From 7894fe5a4d3ff4fa0bcc00a132113b3fd8a2b0aa Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 20:46:47 -0700 Subject: [PATCH] Straw phase M-2: MediaSession + audio focus + service skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the existing Activity-owned ExoPlayer in a Media3 MediaSession. Side effects: - Lock-screen media controls (play/pause) - System media notification with play/pause buttons - Audio focus + ducking (other audio dims when straw plays) - Bluetooth + headset hardware-button routing - Plays nicely alongside other media apps' notifications The ExoPlayer is also configured with AudioAttributes (USAGE_MEDIA + CONTENT_TYPE_MOVIE) and handleAudioFocus=true so other apps can request focus correctly (e.g., a phone call ducks/pauses straw). Also scaffolded but NOT yet wired: - PlaybackService extending MediaSessionService — the foundation for true background-after-Activity-kill audio. Manifest entry + deps added (media3-session 1.4.1, concurrent-futures-ktx 1.2.0). The Activity-to-Service migration via MediaController is M-3 work. - POST_NOTIFICATIONS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PLAYBACK, WAKE_LOCK permissions in manifest. For the user right now: lock the phone while a video plays — media controls appear on the lock screen. Open another app — notification with play/pause sits in the shade. Press a headset's play/pause — straw responds. Pull the home screen — PiP keeps the video floating + audio continues. Full screen-off-background-audio after killing the activity arrives in M-3. --- strawApp/build.gradle.kts | 4 ++ strawApp/src/main/AndroidManifest.xml | 17 +++++++ .../straw/feature/player/PlaybackService.kt | 50 +++++++++++++++++++ .../straw/feature/player/PlayerScreen.kt | 29 ++++++++++- 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt 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,