vc=21: seamless background-audio handoff on 🎧 + HOME

vc=20 fixed channel videos but left two player rough edges that Cobb
called out on the phone:

  * Tapping 🎧 (background audio) restarted the stream from the
    beginning instead of picking up where the foreground player was.
  * Pressing HOME on the player auto-entered Picture-in-Picture; what
    Cobb wants is seamless background audio.

Both paths now share one handoff: capture exoPlayer.currentPosition,
stop the activity player, start PlaybackService with EXTRA_POSITION_MS,
and seekTo(position) on setMediaItem. Verified on emulator: 🎧 tap from
~34s in-track resumes service at position=34821ms.

HOME-on-player triggers the same path via StrawActivity.onUserLeaveHint
→ PlayerLeaveHandler.handler (a tiny registry the active PlayerScreen
registers in a DisposableEffect; cleared on dispose so the hook is a
no-op anywhere else in the app). The previous DisposableEffect that
called setAutoEnterEnabled(true) is gone; manual PiP via the ⊟ overlay
button stays — that one is still useful and user-triggered.

Also fixes a latent IllegalStateException that the new HOME path
exposed: PlaybackService and PlayerScreen both built MediaSession with
the default empty ID, which the system rejects when both live in the
same process. Service now sets .setId("straw-bg") so the two
sessions can coexist during the brief activity-vs-service overlap
during handoff.

Smoke (emulator 1440x3040):
  * Subscriptions feed renders (vc=20 fix carries over).
  * 🎧 from ~34s in NCS / Different Heaven → service playing
    position=34821ms, no session-ID crash.
  * HOME from PlayerScreen → focus moves to NexusLauncher,
    PlaybackService running with isForeground=true,
    pictureInPictureParams=null on the activity (no PiP).
This commit is contained in:
Kayos 2026-05-25 03:55:39 +00:00
parent 709af57f42
commit 599d299b2a
5 changed files with 70 additions and 24 deletions

View file

@ -16,6 +16,14 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
//
// vc=21 / 0.1.0-AG — player hand-off polish:
// * 🎧 background-audio button now captures the current position and
// resumes the foreground service from there instead of restarting.
// * HOME / recents button while on the player now hands off seamlessly
// to background audio (same position-preserving path) instead of
// auto-entering Picture-in-Picture. Manual PiP via the ⊟ overlay
// button is unchanged.
//
// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline
// cutover. vc=19 returned empty subscription feeds because
// strawcore-core's channel_info wasn't doing the second browse for the
@ -24,6 +32,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 20
const val STRAW_VERSION_NAME = "0.1.0-AF"
const val STRAW_VERSION_CODE = 21
const val STRAW_VERSION_NAME = "0.1.0-AG"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.player.PlayerLeaveHandler
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen
@ -109,6 +110,16 @@ class StrawActivity : ComponentActivity() {
}
}
/**
* HOME / recents seamless hand-off to background audio when the
* player screen is active. PlayerScreen registers the handler; any
* other screen leaves it null and home behaves normally.
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
PlayerLeaveHandler.handler?.invoke()
}
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null

View file

@ -101,7 +101,12 @@ class PlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE,
)
// Distinct session ID so we don't collide with the activity-side
// MediaSession (also in this process) when the user hands off from
// PlayerScreen → background audio. Default ID is "" which throws
// IllegalStateException("Session ID must be unique. ID=").
mediaSession = MediaSession.Builder(this, player)
.setId(MEDIA_SESSION_ID)
.setSessionActivity(sessionActivityIntent)
.build()
}
@ -122,6 +127,7 @@ class PlaybackService : MediaSessionService() {
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
val title = intent?.getStringExtra(EXTRA_TITLE)
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
val startPositionMs = intent?.getLongExtra(EXTRA_POSITION_MS, 0L)?.coerceAtLeast(0L) ?: 0L
val player = mediaSession?.player
if (url == null || player == null) {
// HIGH-2: nothing to play (likely a re-launch with null intent
@ -139,7 +145,7 @@ class PlaybackService : MediaSessionService() {
.build(),
)
.build()
player.setMediaItem(item)
player.setMediaItem(item, startPositionMs)
player.prepare()
player.playWhenReady = true
return START_NOT_STICKY
@ -231,8 +237,10 @@ class PlaybackService : MediaSessionService() {
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
const val EXTRA_POSITION_MS = "com.sulkta.straw.extra.POSITION_MS"
private const val NOTIF_CHANNEL_ID = "straw.playback"
private const val NOTIF_ID = 4242
private const val MEDIA_SESSION_ID = "straw-bg"
}
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Bridge between StrawActivity.onUserLeaveHint() (HOME / recents button)
* and the active PlayerScreen. When the user leaves the player by pressing
* HOME, we want a seamless hand-off to the background-audio foreground
* service not Picture-in-Picture. PlayerScreen registers a handler on
* compose, clears it on dispose; the activity calls it from the OS hook.
*/
package com.sulkta.straw.feature.player
object PlayerLeaveHandler {
@Volatile
var handler: (() -> Unit)? = null
}

View file

@ -135,29 +135,29 @@ fun PlayerScreen(
}
}
// PiP setup: on Android 12+ tell the OS this activity can auto-enter
// PiP, so when the user presses Home or swipes away the video shrinks
// into a floating window instead of pausing/exiting. Aspect ratio is
// set eagerly so the system can sample it before the first transition.
val activity = context as? Activity
DisposableEffect(activity) {
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setAutoEnterEnabled(true)
.build()
runCatching { activity.setPictureInPictureParams(params) }
}
onDispose {
// Disable auto-enter when leaving the player so the rest of the
// app doesn't accidentally PiP on background.
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val off = PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
runCatching { activity.setPictureInPictureParams(off) }
// Home / recents button → seamless hand-off to background audio service
// instead of Picture-in-Picture. PiP is still available manually via the
// ⊟ overlay button. We register a handler that the activity calls from
// onUserLeaveHint(); the handler captures currentPosition so the audio
// service resumes from the same point. Same code path that the explicit
// 🎧 button uses.
val resolvedState = androidx.compose.runtime.rememberUpdatedState(state.resolved)
DisposableEffect(Unit) {
PlayerLeaveHandler.handler = handler@{
val r = resolvedState.value ?: return@handler
val audio = r.audioUrl ?: r.combinedUrl ?: return@handler
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
runCatching { exoPlayer.stop() }
runCatching { exoPlayer.clearMediaItems() }
val intent = Intent(context, PlaybackService::class.java).apply {
component = ComponentName(context, PlaybackService::class.java)
putExtra(PlaybackService.EXTRA_URL, audio)
putExtra(PlaybackService.EXTRA_TITLE, title)
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
}
ContextCompat.startForegroundService(context, intent)
}
onDispose { PlayerLeaveHandler.handler = null }
}
// AUD-MED: pause playback when app goes to background. Without this,
@ -382,12 +382,14 @@ fun PlayerScreen(
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
return@OverlayButton
}
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
runCatching { exoPlayer.stop() }
runCatching { exoPlayer.clearMediaItems() }
val intent = Intent(context, PlaybackService::class.java).apply {
component = ComponentName(context, PlaybackService::class.java)
putExtra(PlaybackService.EXTRA_URL, audio)
putExtra(PlaybackService.EXTRA_TITLE, title)
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(