vc=36: audit-fix tail — atomic setPlayingFrom, cache wipe, polish

The deferred items from the vc=35 audit-fix sprint. Smaller surface,
real impact:

HIGH-C6 — atomic setPlayingFrom claim
  StrawMediaController.setPlayingFrom previously did
    if (NowPlaying.current.value?.streamUrl == streamUrl) return
    setMediaItem(...); prepare(); play()
    NowPlaying.set(...)
  When the inline player and fullscreen Player effects fired in the
  same composition pass (an inline → fullscreen transition), both
  checks could see the stale NowPlaying value, both passed the
  guard, both ran setMediaItem + prepare + play. Result: an audible
  "did the video just restart?" stutter that was hard to reproduce.
  New: NowPlaying.claim(item) uses MutableStateFlow.compareAndSet
  in a CAS loop. Returns true ONLY for the caller that won the
  race; losing caller bails before touching the controller. The
  guard is now actually atomic, not a check-then-set.

MED-Q11 — minibar surfaces playback errors
  Background button takes the user to Home with audio continuing in
  the foreground service. If that audio then fails (transient network
  drop on the resolved URL), neither the inline-player error listener
  nor PlayerScreen's exist anymore — only the minibar is observing.
  Added onPlayerError to MinibarOverlay's listener: Toast the
  errorCodeName + clear NowPlaying so the minibar hides itself
  rather than claiming a dead session is loaded.

MED-Q15 — pre-compute recencyScore once
  mergeFromCache's compareByDescending invoked recencyScore() twice
  per pair (compareBy semantics), so ~1800 regex matches on a 900-
  item merge. Pair the score with the item once, sort the pair, take
  the items back. N matches.

MED-C13 — Settings cache-wipe also clears in-memory VM
  SubscriptionFeedViewModel.clearInMemoryCache() exposed; Settings's
  Switch.onCheckedChange(false) now calls it alongside the disk
  wipe. Without this the feed kept rendering its in-memory mirror
  until process death.

MED-C5 — drop StrawHome.formatDurationShort
  Near-duplicate of util.formatDuration. Used util's version + the
  existing `if (durationSeconds > 0)` guard at the call site already
  produces identical output (util returns "" on sec <= 0).

MED-C19 — drop unused Surface import in StrawHome.

NowPlaying gained one public method (claim). Everything else is
internal-only churn.
This commit is contained in:
Kayos 2026-05-25 13:43:45 -07:00
parent 8f7ec129b3
commit d1ee9379e0
7 changed files with 83 additions and 24 deletions

View file

@ -48,7 +48,6 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -81,6 +80,7 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch
@ -524,7 +524,7 @@ private fun ThumbnailWithDuration(
)
if (durationSeconds > 0) {
Text(
text = formatDurationShort(durationSeconds),
text = formatDuration(durationSeconds),
style = MaterialTheme.typography.labelSmall,
color = androidx.compose.ui.graphics.Color.White,
modifier = Modifier
@ -538,13 +538,6 @@ private fun ThumbnailWithDuration(
}
}
private fun formatDurationShort(totalSec: Long): String {
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
@Composable
private fun SubChip(
ch: ChannelRef,

View file

@ -214,19 +214,34 @@ class SubscriptionFeedViewModel : ViewModel() {
private fun mergeFromCache(channels: List<ChannelRef>): List<StreamItem> {
val subUrls = channels.map { it.url }.toSet()
// Drop cache entries for unsubscribed channels so removed subs
// fall out of the feed immediately.
// fall out of the feed immediately. (Has a side effect on the
// ConcurrentHashMap — kept here for atomicity vs. a separate
// pass.)
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
// Newest-first across channels. Pre-compute recencyScore once
// per item — vc=35 audit MED-Q15: sortedWith's comparator was
// invoking the regex twice per pair, so ~1800 regex matches on
// a 900-item merge. Pairing the score before sort drops that
// to N matches.
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
// Newest-first across channels. Falls back to viewCount when
// we couldn't parse the relative date (older items + live
// streams come back without one).
.map { it to it.recencyScore() }
.sortedWith(
compareByDescending<StreamItem> { it.recencyScore() }
.thenByDescending { it.viewCount },
compareByDescending<Pair<StreamItem, Long>> { it.second }
.thenByDescending { it.first.viewCount },
)
// Generous cap. Anything past this is almost certainly noise
// for a feed view; pagination in the UI further slices this.
.take(500)
.map { it.first }
}
/**
* Clear in-memory cache. Called from Settings when the user flips
* off the local-cache toggle disk wipe via FeedCacheStore.clear()
* was already there, but the VM kept its in-memory mirror so items
* stayed visible until process death. vc=35 audit MED-C13.
*/
fun clearInMemoryCache() {
channelCache.clear()
_ui.value = _ui.value.copy(items = emptyList(), lastFetchedAt = 0L)
}
}

View file

@ -69,11 +69,25 @@ fun MinibarOverlay(
// Reflect the controller's play state in the play/pause icon. Listening
// is the only reliable way; isPlaying snapshots stale between events.
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
val ctx = androidx.compose.ui.platform.LocalContext.current
DisposableEffect(controller) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
// vc=35 audit MED-Q11: if Background-button took the user
// to Home and the foreground audio fails, the only Player
// surface still listening is this minibar. Surface a Toast
// + clear NowPlaying so the minibar hides itself rather
// than claiming an already-dead session is "loaded".
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.widget.Toast.makeText(
ctx,
"playback error: ${error.errorCodeName}",
android.widget.Toast.LENGTH_LONG,
).show()
NowPlaying.clear()
}
}
controller.addListener(listener)
isPlaying = controller.isPlaying

View file

@ -36,6 +36,31 @@ object NowPlaying {
_current.value = item
}
/**
* Atomically claim playback for `streamUrl`. Returns true if this
* call WON the claim (caller should now do setMediaItem + prepare +
* play). Returns false if someone else has already set the same
* streamUrl typically because the inline-player effect and the
* fullscreen Player effect both fired in the same window during
* an inlinefullscreen transition. The losing caller does nothing;
* the winning caller's playback is already in flight.
*
* Uses MutableStateFlow.compareAndSet for the race-free transition.
* vc=35 audit HIGH-C6 the previous "check NowPlaying then
* setPlayingFrom" sequence had a window where both checks could
* pass before either NowPlaying.set ran.
*/
fun claim(item: NowPlayingItem): Boolean {
while (true) {
val cur = _current.value
if (cur?.streamUrl == item.streamUrl) return false
if (_current.compareAndSet(cur, item)) return true
// Lost the CAS to a concurrent writer — retry against the
// fresh state. Bounded: at most a handful of competing
// callers in practice.
}
}
fun clear() {
_current.value = null
}

View file

@ -82,11 +82,12 @@ fun MediaController.setPlayingFrom(
resolved: ResolvedPlayback,
startPositionMs: Long = 0L,
) {
val item = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
setMediaItem(item, startPositionMs)
prepare()
playWhenReady = true
NowPlaying.set(
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
// Atomic claim BEFORE any controller mutation. If a concurrent
// caller already set this URL (inline player + fullscreen Player
// racing each other on the same transition), we bail before
// double-priming the player. vc=35 audit HIGH-C6.
val claimed = NowPlaying.claim(
NowPlayingItem(
streamUrl = streamUrl,
title = title,
@ -95,6 +96,10 @@ fun MediaController.setPlayingFrom(
segments = resolved.segments,
),
)
if (!claimed) return
setMediaItem(mediaItem, startPositionMs)
prepare()
playWhenReady = true
}
@UnstableApi

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import android.widget.Toast
import androidx.lifecycle.viewmodel.compose.viewModel
import com.sulkta.straw.data.FeedCache
import com.sulkta.straw.data.History
import com.sulkta.straw.data.MaxResolution
@ -50,6 +51,7 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.dataimport.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.util.LogDump
import kotlinx.coroutines.launch
@ -200,6 +202,7 @@ fun SettingsScreen() {
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
val feedVm: SubscriptionFeedViewModel = viewModel()
Switch(
checked = cacheEnabled,
onCheckedChange = { checked ->
@ -209,6 +212,10 @@ fun SettingsScreen() {
// defeats the purpose of opting out.
FeedCache.get().clear()
SearchCache.get().clear()
// vc=35 audit MED-C13 — wipe the in-memory
// copy too, otherwise items stayed visible
// until process death.
feedVm.clearInMemoryCache()
}
},
)