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
// 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"

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.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,

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_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<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) {
// 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<SbCategory> {
val raw = sp.getStringSet(KEY_SB_CATS, 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.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(

View file

@ -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 {

View file

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

View file

@ -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<MediaController?> { 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

View file

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

View file

@ -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,

View file

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

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