From 599d299b2a0d146d207793e67b57a52b8ae4cd37 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 03:55:39 +0000 Subject: [PATCH] =?UTF-8?q?vc=3D21:=20seamless=20background-audio=20handof?= =?UTF-8?q?f=20on=20=F0=9F=8E=A7=20+=20HOME=20vc=3D20=20fixed=20channel=20?= =?UTF-8?q?videos=20but=20left=20two=20player=20rough=20edges=20that=20Cob?= =?UTF-8?q?b=20called=20out=20on=20the=20phone:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 12 ++++- .../kotlin/com/sulkta/straw/StrawActivity.kt | 11 +++++ .../straw/feature/player/PlaybackService.kt | 10 ++++- .../feature/player/PlayerLeaveHandler.kt | 17 +++++++ .../straw/feature/player/PlayerScreen.kt | 44 ++++++++++--------- 5 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 1552677c7..15a86913b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 670db54e3..57746b38c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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 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 index 5cf85506c..8e20dfb04 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -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" } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt new file mode 100644 index 000000000..e7f57495a --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerLeaveHandler.kt @@ -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 +} 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 caf3d79ef..8a7e6c1ad 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 @@ -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(