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
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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