vc=51: bottom-clear sweep + DASH res cap + headphone pause

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).
This commit is contained in:
Kayos 2026-05-26 08:14:16 -07:00
parent 208cdf6326
commit dc1fff00db
11 changed files with 161 additions and 9 deletions

View file

@ -55,6 +55,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-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 50 const val STRAW_VERSION_CODE = 51
const val STRAW_VERSION_NAME = "0.1.0-BJ" const val STRAW_VERSION_NAME = "0.1.0-BK"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -83,6 +83,7 @@ import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
@ -258,7 +259,7 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
LazyColumn { LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(watches) { w -> items(watches) { w ->
RecentRow( RecentRow(
item = w, item = w,
@ -436,7 +437,10 @@ private fun SubsPane(
} }
} }
} }
LazyColumn(state = listState) { LazyColumn(
state = listState,
contentPadding = rememberBottomContentPadding(),
) {
items(displayed) { item -> items(displayed) { item ->
FeedRow( FeedRow(
item = item, item = item,

View file

@ -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_MODE = "autoplay_mode_v1"
private const val KEY_AUTOPLAY_SKIP_WATCHED = "autoplay_skip_watched_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_AUTOSTART_PLAYBACK = "autostart_playback_v1"
private const val KEY_PAUSE_ON_HEADPHONE_DISCONNECT = "pause_on_headphone_disconnect_v1"
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@ -103,6 +104,18 @@ class SettingsStore(context: Context) {
) )
val autoStartPlayback: StateFlow<Boolean> = _autoStartPlayback.asStateFlow() val autoStartPlayback: StateFlow<Boolean> = _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<Boolean> = _pauseOnHeadphoneDisconnect.asStateFlow()
fun toggle(cat: SbCategory) { fun toggle(cat: SbCategory) {
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore. // Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
val next = _sbCategories.updateAndGet { cur -> val next = _sbCategories.updateAndGet { cur ->
@ -158,6 +171,13 @@ class SettingsStore(context: Context) {
sp.edit().putBoolean(KEY_AUTOSTART_PLAYBACK, autoStart).apply() 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<SbCategory> { private fun loadCategories(): Set<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, null) val raw = sp.getStringSet(KEY_SB_CATS, null)
return if (raw == null) { return if (raw == null) {

View file

@ -55,6 +55,7 @@ import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatDuration
@Composable @Composable
@ -86,7 +87,10 @@ fun ChannelScreen(
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error) Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
} }
else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) { else -> LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = rememberBottomContentPadding(),
) {
item { item {
state.banner?.let { b -> state.banner?.let { b ->
AsyncImage( AsyncImage(

View file

@ -55,6 +55,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.sulkta.straw.util.rememberBottomContentPadding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -134,7 +135,7 @@ fun DownloadsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
LazyColumn { LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(rows, key = { it.id }) { row -> items(rows, key = { it.id }) { row ->
DownloadRowView(row, context, onRemove = { DownloadRowView(row, context, onRemove = {
runCatching { runCatching {

View file

@ -55,6 +55,8 @@ import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.runCatchingCancellable import com.sulkta.straw.util.runCatchingCancellable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -62,6 +64,7 @@ import kotlinx.coroutines.withContext
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
private var settingsWatcherJob: Job? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -87,6 +90,12 @@ class PlaybackService : MediaSessionService() {
.build(), .build(),
/* handleAudioFocus = */ true, /* 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() .build()
// Service shutdown is driven by onTaskRemoved (user swiped app away) // Service shutdown is driven by onTaskRemoved (user swiped app away)
@ -109,6 +118,17 @@ class PlaybackService : MediaSessionService() {
.setSessionActivity(sessionActivityIntent) .setSessionActivity(sessionActivityIntent)
.build() .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 // Queue auto-advance bridge: when Media3 transitions to the
// next item in the queue, look up the matching NowPlayingItem // next item in the queue, look up the matching NowPlayingItem
// (with original streamUrl, uploader, thumbnail, SB segments) // (with original streamUrl, uploader, thumbnail, SB segments)
@ -267,6 +287,8 @@ class PlaybackService : MediaSessionService() {
} }
override fun onDestroy() { override fun onDestroy() {
settingsWatcherJob?.cancel()
settingsWatcherJob = null
// Null the field first so a late onGetSession during teardown gets // Null the field first so a late onGetSession during teardown gets
// null rather than a released session. // null rather than a released session.
val s = mediaSession val s = mediaSession

View file

@ -38,10 +38,12 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.ResolvedPlayback import com.sulkta.straw.feature.detail.ResolvedPlayback
val LocalStrawController = compositionLocalOf<MediaController?> { null } val LocalStrawController = compositionLocalOf<MediaController?> { null }
@ -104,11 +106,34 @@ fun Player.setPlayingFrom(
// long-press-enqueues more items, append/insertAt keep them // long-press-enqueues more items, append/insertAt keep them
// synced. // synced.
Queue.setAll(nowPlayingItem) 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) setMediaItem(mediaItem, startPositionMs)
prepare() prepare()
playWhenReady = true 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 * 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 * item. If the player is idle (no current item), fall through to a

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Playlists
import com.sulkta.straw.util.rememberBottomContentPadding
@Composable @Composable
fun PlaylistsScreen( fun PlaylistsScreen(
@ -87,7 +88,7 @@ fun PlaylistsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
LazyColumn { LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(playlists, key = { it.id }) { pl -> items(playlists, key = { it.id }) { pl ->
Row( Row(
modifier = Modifier modifier = Modifier
@ -221,7 +222,7 @@ fun PlaylistViewScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
LazyColumn { LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(playlist.items, key = { it.streamUrl }) { item -> items(playlist.items, key = { it.streamUrl }) { item ->
Row( Row(
modifier = Modifier modifier = Modifier

View file

@ -54,6 +54,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.rememberBottomContentPadding
@Composable @Composable
fun SearchScreen( fun SearchScreen(
@ -160,7 +161,10 @@ fun SearchScreen(
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 4.dp),
) )
} }
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberBottomContentPadding(),
) {
items(state.results) { item -> items(state.results) { item ->
ResultRow( ResultRow(
item = item, item = item,

View file

@ -278,6 +278,32 @@ fun SettingsScreen() {
onCheckedChange = { store.setAutoStartPlayback(it) }, 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)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(

View file

@ -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())