Straw phase M-2: MediaSession + audio focus + service skeleton

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.
This commit is contained in:
Kayos 2026-05-23 20:46:47 -07:00
parent 1578de5dbb
commit 7894fe5a4d
4 changed files with 98 additions and 2 deletions

View file

@ -106,4 +106,8 @@ dependencies {
implementation("androidx.media3:media3-exoplayer-dash:1.4.1") implementation("androidx.media3:media3-exoplayer-dash:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1") implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-ui: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")
} }

View file

@ -3,6 +3,13 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Background audio playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Android 13+ runtime notification permission for media controls -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Wake while audio plays -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".StrawApp" android:name=".StrawApp"
@ -39,5 +46,15 @@
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Phase M-2: MediaSessionService for background audio + notification + lock-screen controls. -->
<service
android:name=".feature.player.PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View file

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

View file

@ -41,11 +41,14 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.MergingMediaSource
@ -68,11 +71,33 @@ fun PlayerScreen(
LaunchedEffect(streamUrl) { vm.resolve(streamUrl) } LaunchedEffect(streamUrl) { vm.resolve(streamUrl) }
val exoPlayer = remember { 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) { DisposableEffect(Unit) {
onDispose { exoPlayer.release() } onDispose {
mediaSession.release()
exoPlayer.release()
}
} }
// AUD-MED: pause playback when app goes to background. Without this, // AUD-MED: pause playback when app goes to background. Without this,