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:
parent
1578de5dbb
commit
7894fe5a4d
4 changed files with 98 additions and 2 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue