vc=21: seamless background-audio handoff on 🎧 + HOME
vc=20 fixed channel videos but left two player rough edges that a tester 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 a tester 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:
parent
cb4ec175db
commit
0ad02b0c74
5 changed files with 70 additions and 24 deletions
|
|
@ -16,6 +16,14 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// 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
|
// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline
|
||||||
// cutover. vc=19 returned empty subscription feeds because
|
// cutover. vc=19 returned empty subscription feeds because
|
||||||
// strawcore-core's channel_info wasn't doing the second browse for the
|
// 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
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 20
|
const val STRAW_VERSION_CODE = 21
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AF"
|
const val STRAW_VERSION_NAME = "0.1.0-AG"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.sulkta.straw.feature.channel.ChannelScreen
|
import com.sulkta.straw.feature.channel.ChannelScreen
|
||||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
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.player.PlayerScreen
|
||||||
import com.sulkta.straw.feature.search.SearchScreen
|
import com.sulkta.straw.feature.search.SearchScreen
|
||||||
import com.sulkta.straw.feature.settings.SettingsScreen
|
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). */
|
/** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
|
||||||
private fun pickYouTubeUrl(intent: Intent?): String? {
|
private fun pickYouTubeUrl(intent: Intent?): String? {
|
||||||
intent ?: return null
|
intent ?: return null
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,12 @@ class PlaybackService : MediaSessionService() {
|
||||||
PendingIntent.FLAG_IMMUTABLE,
|
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)
|
mediaSession = MediaSession.Builder(this, player)
|
||||||
|
.setId(MEDIA_SESSION_ID)
|
||||||
.setSessionActivity(sessionActivityIntent)
|
.setSessionActivity(sessionActivityIntent)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +127,7 @@ class PlaybackService : MediaSessionService() {
|
||||||
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
|
val url = intent?.getStringExtra(EXTRA_URL)?.takeIf { isAllowedAudioUrl(it) }
|
||||||
val title = intent?.getStringExtra(EXTRA_TITLE)
|
val title = intent?.getStringExtra(EXTRA_TITLE)
|
||||||
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
val uploader = intent?.getStringExtra(EXTRA_UPLOADER)
|
||||||
|
val startPositionMs = intent?.getLongExtra(EXTRA_POSITION_MS, 0L)?.coerceAtLeast(0L) ?: 0L
|
||||||
val player = mediaSession?.player
|
val player = mediaSession?.player
|
||||||
if (url == null || player == null) {
|
if (url == null || player == null) {
|
||||||
// HIGH-2: nothing to play (likely a re-launch with null intent
|
// HIGH-2: nothing to play (likely a re-launch with null intent
|
||||||
|
|
@ -139,7 +145,7 @@ class PlaybackService : MediaSessionService() {
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
player.setMediaItem(item)
|
player.setMediaItem(item, startPositionMs)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
|
|
@ -231,8 +237,10 @@ class PlaybackService : MediaSessionService() {
|
||||||
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
const val EXTRA_URL = "com.sulkta.straw.extra.URL"
|
||||||
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
const val EXTRA_TITLE = "com.sulkta.straw.extra.TITLE"
|
||||||
const val EXTRA_UPLOADER = "com.sulkta.straw.extra.UPLOADER"
|
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_CHANNEL_ID = "straw.playback"
|
||||||
private const val NOTIF_ID = 4242
|
private const val NOTIF_ID = 4242
|
||||||
|
private const val MEDIA_SESSION_ID = "straw-bg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
@ -135,29 +135,29 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PiP setup: on Android 12+ tell the OS this activity can auto-enter
|
// Home / recents button → seamless hand-off to background audio service
|
||||||
// PiP, so when the user presses Home or swipes away the video shrinks
|
// instead of Picture-in-Picture. PiP is still available manually via the
|
||||||
// into a floating window instead of pausing/exiting. Aspect ratio is
|
// ⊟ overlay button. We register a handler that the activity calls from
|
||||||
// set eagerly so the system can sample it before the first transition.
|
// onUserLeaveHint(); the handler captures currentPosition so the audio
|
||||||
val activity = context as? Activity
|
// service resumes from the same point. Same code path that the explicit
|
||||||
DisposableEffect(activity) {
|
// 🎧 button uses.
|
||||||
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val resolvedState = androidx.compose.runtime.rememberUpdatedState(state.resolved)
|
||||||
val params = PictureInPictureParams.Builder()
|
DisposableEffect(Unit) {
|
||||||
.setAspectRatio(Rational(16, 9))
|
PlayerLeaveHandler.handler = handler@{
|
||||||
.setAutoEnterEnabled(true)
|
val r = resolvedState.value ?: return@handler
|
||||||
.build()
|
val audio = r.audioUrl ?: r.combinedUrl ?: return@handler
|
||||||
runCatching { activity.setPictureInPictureParams(params) }
|
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||||
}
|
runCatching { exoPlayer.stop() }
|
||||||
onDispose {
|
runCatching { exoPlayer.clearMediaItems() }
|
||||||
// Disable auto-enter when leaving the player so the rest of the
|
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||||
// app doesn't accidentally PiP on background.
|
component = ComponentName(context, PlaybackService::class.java)
|
||||||
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||||
val off = PictureInPictureParams.Builder()
|
putExtra(PlaybackService.EXTRA_TITLE, title)
|
||||||
.setAutoEnterEnabled(false)
|
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
|
||||||
.build()
|
|
||||||
runCatching { activity.setPictureInPictureParams(off) }
|
|
||||||
}
|
}
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
|
onDispose { PlayerLeaveHandler.handler = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUD-MED: pause playback when app goes to background. Without this,
|
// 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()
|
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
|
||||||
return@OverlayButton
|
return@OverlayButton
|
||||||
}
|
}
|
||||||
|
val position = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||||
runCatching { exoPlayer.stop() }
|
runCatching { exoPlayer.stop() }
|
||||||
runCatching { exoPlayer.clearMediaItems() }
|
runCatching { exoPlayer.clearMediaItems() }
|
||||||
val intent = Intent(context, PlaybackService::class.java).apply {
|
val intent = Intent(context, PlaybackService::class.java).apply {
|
||||||
component = ComponentName(context, PlaybackService::class.java)
|
component = ComponentName(context, PlaybackService::class.java)
|
||||||
putExtra(PlaybackService.EXTRA_URL, audio)
|
putExtra(PlaybackService.EXTRA_URL, audio)
|
||||||
putExtra(PlaybackService.EXTRA_TITLE, title)
|
putExtra(PlaybackService.EXTRA_TITLE, title)
|
||||||
|
putExtra(PlaybackService.EXTRA_POSITION_MS, position)
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue