From dc1fff00db0ccfd0375b91159abfd652a81cac68 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 08:14:16 -0700 Subject: [PATCH] vc=51: bottom-clear sweep + DASH res cap + headphone pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three landing together. Bottom-clear sweep: Channel + Search + Subs feed + History + Playlists + Downloads all had content rendering under the system nav bar and the minibar overlay. Added a shared `util/BottomInsets.kt` `rememberBottomContentPadding()` that combines nav-bar inset + reactive 72dp minibar reserve (zero when nothing's playing). Plumbed into every LazyColumn's contentPadding. Same pattern Settings vc=50 used, lifted into a reusable helper. DASH/HLS max-resolution cap: Round-7 audit MED-3 — the user's max-resolution preference only affected the videoOnly/combined picker. DASH manifests bypassed the cap because Media3's ABR picked variants freely. New Player.applyMaxResolutionCap() pushes TrackSelectionParameters .setMaxVideoSize(MAX, ceiling) into the controller before prepare(). Auto = MAX_VALUE = unconstrained. Mid-stream setting changes take effect on next video. Pause on headphone disconnect: User-reported bug — wired headphones died, player switched to phone speaker instead of pausing. ExoPlayer's setHandleAudioBecomingNoisy honors Android's AUDIO_BECOMING_NOISY broadcast and pauses on the standard "headphones pulled" event. Wired into PlaybackService at construction + StrawApp.globalScope collector so flipping the setting mid-session takes effect on the already-built ExoPlayer. New Settings → Pause on headphone disconnect toggle, default on (matches every other Android media app's UX). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 8 +++- .../com/sulkta/straw/data/SettingsStore.kt | 20 +++++++++ .../straw/feature/channel/ChannelScreen.kt | 6 ++- .../straw/feature/download/DownloadsScreen.kt | 3 +- .../straw/feature/player/PlaybackService.kt | 22 +++++++++ .../feature/player/StrawMediaController.kt | 25 +++++++++++ .../straw/feature/playlist/PlaylistsScreen.kt | 5 ++- .../straw/feature/search/SearchScreen.kt | 6 ++- .../straw/feature/settings/SettingsScreen.kt | 26 +++++++++++ .../com/sulkta/straw/util/BottomInsets.kt | 45 +++++++++++++++++++ 11 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 038626994..73ed00783 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,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 = 50 -const val STRAW_VERSION_NAME = "0.1.0-BJ" +const val STRAW_VERSION_CODE = 51 +const val STRAW_VERSION_NAME = "0.1.0-BK" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 2170bb823..251fcceda 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -83,6 +83,7 @@ import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews @@ -258,7 +259,7 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(watches) { w -> RecentRow( item = w, @@ -436,7 +437,10 @@ private fun SubsPane( } } } - LazyColumn(state = listState) { + LazyColumn( + state = listState, + contentPadding = rememberBottomContentPadding(), + ) { items(displayed) { item -> FeedRow( item = item, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 4a1c79780..05dbafae9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -66,6 +66,7 @@ private const val KEY_CACHE_ENABLED = "cache_enabled_v1" private const val KEY_AUTOPLAY_MODE = "autoplay_mode_v1" private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_v1" private const val KEY_AUTOSTART_PLAYBACK = "autostart_playback_v1" +private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1" class SettingsStore(context: Context) { private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -103,6 +104,18 @@ class SettingsStore(context: Context) { ) val autoStartPlayback: StateFlow = _autoStartPlayback.asStateFlow() + /** + * Honor Android's AUDIO_BECOMING_NOISY broadcast — wired headphones + * yanked / Bluetooth disconnect → pause instead of switching to the + * phone speaker. Default on; matches every other Android media app. + * Off lets playback follow the audio focus default (phone speaker + * takes over). + */ + private val _pauseOnHeadphoneDisconnect = MutableStateFlow( + sp.getBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, true), + ) + val pauseOnHeadphoneDisconnect: StateFlow = _pauseOnHeadphoneDisconnect.asStateFlow() + fun toggle(cat: SbCategory) { // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. val next = _sbCategories.updateAndGet { cur -> @@ -158,6 +171,13 @@ class SettingsStore(context: Context) { sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply() } + fun setPauseOnHeadphoneDisconnect(pause: Boolean) { + val before = _pauseOnHeadphoneDisconnect.value + if (before == pause) return + _pauseOnHeadphoneDisconnect.value = pause + sp.edit().putBoolean(KEY_PAUSE_ON_HEADPHONE_DISCONNECT, pause).apply() + } + private fun loadCategories(): Set { val raw = sp.getStringSet(KEY_SB_CATS, null) return if (raw == null) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 54e41dd0f..6333612d4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -55,6 +55,7 @@ import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.formatCount +import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.util.formatDuration @Composable @@ -86,7 +87,10 @@ fun ChannelScreen( Text("error: ${state.error}", color = MaterialTheme.colorScheme.error) } - else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) { + else -> LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = rememberBottomContentPadding(), + ) { item { state.banner?.let { b -> AsyncImage( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt index 6689b0508..5a2f6d2f3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/DownloadsScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.sulkta.straw.util.rememberBottomContentPadding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -134,7 +135,7 @@ fun DownloadsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(rows, key = { it.id }) { row -> DownloadRowView(row, context, onRemove = { runCatching { 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 e39194ee0..3cae0426a 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 @@ -55,6 +55,8 @@ import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.util.runCatchingCancellable import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,6 +64,7 @@ import kotlinx.coroutines.withContext class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null + private var settingsWatcherJob: Job? = null override fun onCreate() { super.onCreate() @@ -87,6 +90,12 @@ class PlaybackService : MediaSessionService() { .build(), /* handleAudioFocus = */ true, ) + // Honor the user's pause-on-headphone-disconnect preference + // at construction time. The Settings flow is also watched + // below so flipping it mid-session takes effect immediately. + .setHandleAudioBecomingNoisy( + Settings.get().pauseOnHeadphoneDisconnect.value, + ) .build() // Service shutdown is driven by onTaskRemoved (user swiped app away) @@ -109,6 +118,17 @@ class PlaybackService : MediaSessionService() { .setSessionActivity(sessionActivityIntent) .build() + // Watch the pause-on-headphone-disconnect setting so flipping + // it in Settings takes effect on this already-built ExoPlayer + // without requiring a service restart. The initial value was + // baked in via the builder above — this picks up subsequent + // flips. + settingsWatcherJob = StrawApp.globalScope.launch { + Settings.get().pauseOnHeadphoneDisconnect.collect { handle -> + player.setHandleAudioBecomingNoisy(handle) + } + } + // Queue auto-advance bridge: when Media3 transitions to the // next item in the queue, look up the matching NowPlayingItem // (with original streamUrl, uploader, thumbnail, SB segments) @@ -267,6 +287,8 @@ class PlaybackService : MediaSessionService() { } override fun onDestroy() { + settingsWatcherJob?.cancel() + settingsWatcherJob = null // Null the field first so a late onGetSession during teardown gets // null rather than a released session. val s = mediaSession diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 1932f1726..9edb2a3ab 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -38,10 +38,12 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.MoreExecutors +import com.sulkta.straw.data.Settings import com.sulkta.straw.feature.detail.ResolvedPlayback val LocalStrawController = compositionLocalOf { null } @@ -104,11 +106,34 @@ fun Player.setPlayingFrom( // long-press-enqueues more items, append/insertAt keep them // synced. Queue.setAll(nowPlayingItem) + // Apply the user's max-resolution cap to DASH/HLS adaptive + // streams. Round-7 audit MED-3 — the cap previously only + // affected the videoOnly/combined picker; DASH manifests + // bypassed it because Media3 picked variants freely. setMaxVideoSize + // tells the ABR algorithm to never pick anything taller than + // ceiling. Auto = Int.MAX_VALUE = no constraint. + applyMaxResolutionCap() setMediaItem(mediaItem, startPositionMs) prepare() playWhenReady = true } +/** + * Push the current Settings.maxResolution into the controller's + * TrackSelectionParameters as a height cap. Idempotent — safe to + * call repeatedly. Called inside setPlayingFrom so every new + * playback respects the live preference; setting changes mid-stream + * apply on next video. + */ +@UnstableApi +fun Player.applyMaxResolutionCap() { + val ceiling = Settings.get().maxResolution.value.ceiling + val maxHeight = if (ceiling >= Int.MAX_VALUE) Int.MAX_VALUE else ceiling + trackSelectionParameters = trackSelectionParameters.buildUpon() + .setMaxVideoSize(Int.MAX_VALUE, maxHeight) + .build() +} + /** * Add a video to the playback queue right after the currently-playing * item. If the player is idle (no current item), fall through to a diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt index 44c1ad36f..a512c6565 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.sulkta.straw.data.Playlists +import com.sulkta.straw.util.rememberBottomContentPadding @Composable fun PlaylistsScreen( @@ -87,7 +88,7 @@ fun PlaylistsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(playlists, key = { it.id }) { pl -> Row( modifier = Modifier @@ -221,7 +222,7 @@ fun PlaylistViewScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn { + LazyColumn(contentPadding = rememberBottomContentPadding()) { items(playlist.items, key = { it.streamUrl }) { item -> Row( modifier = Modifier diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 9dfed4063..c4e23d6a1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews +import com.sulkta.straw.util.rememberBottomContentPadding @Composable fun SearchScreen( @@ -160,7 +161,10 @@ fun SearchScreen( modifier = Modifier.padding(bottom = 4.dp), ) } - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberBottomContentPadding(), + ) { items(state.results) { item -> ResultRow( item = item, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index b79b2dd5d..784baa887 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -278,6 +278,32 @@ fun SettingsScreen() { onCheckedChange = { store.setAutoStartPlayback(it) }, ) } + val pauseOnHeadphones by store.pauseOnHeadphoneDisconnect.collectAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Pause on headphone disconnect", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Wired pull / Bluetooth drop → pause instead of " + + "switching to the phone speaker.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = pauseOnHeadphones, + onCheckedChange = { store.setPauseOnHeadphoneDisconnect(it) }, + ) + } Spacer(modifier = Modifier.height(32.dp)) Text( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt new file mode 100644 index 000000000..b814a98a2 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/BottomInsets.kt @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Shared bottom-padding helper for every scrolling screen. + * + * Two things float over the bottom of the activity-level layout and + * need to be cleared: + * 1. The system navigation bar (3-button or gesture). Insets via + * WindowInsets.navigationBars. + * 2. The Straw minibar overlay. Reactive — only present when + * NowPlaying.current is non-null. ~64dp tall + a small gap → + * 72dp reserve. + * + * LazyColumn-based screens plumb this into `contentPadding` so items + * scroll PAST the bottom without being eaten. verticalScroll columns + * append a tail Spacer of the same height. + */ + +package com.sulkta.straw.util + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sulkta.straw.feature.player.NowPlaying + +/** Combined bottom Dp: nav-bar inset + 72dp when minibar's visible. */ +@Composable +fun rememberBottomBarReserveDp(): Dp { + val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val item by NowPlaying.current.collectAsStateWithLifecycle() + val minibar = if (item != null) 72.dp else 0.dp + return navBottom + minibar +} + +/** Convenience for LazyColumn.contentPadding — adds nothing on the top/start/end. */ +@Composable +fun rememberBottomContentPadding(): PaddingValues = + PaddingValues(bottom = rememberBottomBarReserveDp())