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:
parent
208cdf6326
commit
dc1fff00db
11 changed files with 161 additions and 9 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue