Watched-status: persistent red bar + our play count (vc=74 items 2,3)

Item 2: thumbnail progress bar now stays full on watched videos — falls back to watch-history when the live resume point is gone (was vanishing on finished videos). A live resume entry still wins for mid-watch progress. Item 3: HistoryStore tracks a per-video playCount (increments each watch, carried forward atomically in recordWatch; defaults 1 for pre-vc74 entries). VideoDetail shows 'Watched N times' under the view count.
This commit is contained in:
Cobb 2026-06-20 09:53:51 -07:00
parent 6f95b6fa3d
commit dd2345b1c8
3 changed files with 50 additions and 7 deletions

View file

@ -26,6 +26,10 @@ data class WatchHistoryItem(
val uploader: String,
val thumbnail: String?,
val watchedAt: Long,
// How many times WE'VE played this video. Defaults to 1 so entries
// serialized before vc=74 (no field) deserialize as a single watch.
// Incremented in recordWatch when the videoId is already in history.
val playCount: Int = 1,
)
private const val PREFS = "straw_history"
@ -62,13 +66,18 @@ class HistoryStore(context: Context) {
val searches: StateFlow<List<String>> = _searches.asStateFlow()
fun recordWatch(item: WatchHistoryItem) {
val now = item.copy(watchedAt = System.currentTimeMillis())
val ts = System.currentTimeMillis()
// Atomic read-modify-write — two concurrent recordWatch calls
// both reading the same `current` and one clobbering the other
// is exactly the bug updateAndGet avoids.
// is exactly the bug updateAndGet avoids. The play count is
// carried forward from the prior entry + 1, computed INSIDE the
// CAS lambda so concurrent watches each increment correctly
// (item.playCount from the caller is ignored — store wins).
val next = _watches.updateAndGet { current ->
val prior = current.firstOrNull { it.videoId == item.videoId }?.playCount ?: 0
val merged = item.copy(watchedAt = ts, playCount = prior + 1)
val without = current.filterNot { it.videoId == item.videoId }
(listOf(now) + without).take(maxWatches())
(listOf(merged) + without).take(maxWatches())
}
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}

View file

@ -433,6 +433,26 @@ fun VideoDetailScreen(
}
}
// "Watched N times" — our own play count, sitting under the
// view count (vc=74). collectAsState is called unconditionally
// (stable composable call); only the Text is gated, and only
// once we've actually played it. Sourced from HistoryStore by
// this video's id.
val watchedVideoId = extractYtVideoId(streamUrl)
val watchHist by com.sulkta.straw.data.History.get().watches.collectAsState()
val plays = remember(watchHist, watchedVideoId) {
if (watchedVideoId == null) 0
else watchHist.firstOrNull { it.videoId == watchedVideoId }?.playCount ?: 0
}
if (plays > 0) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "▶ Watched $plays time${if (plays == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action bar — uniform tonal pills in a single horizontally
// scrollable row so they never wrap into a ragged block. The

View file

@ -70,10 +70,24 @@ fun BoxScope.ThumbnailProgressOverlay(videoId: String?) {
val entry by remember(videoId) {
derivedStateOf { positions[videoId] }
}
val resolved = entry ?: return
if (resolved.durationMs <= 0L) return
val fraction = (resolved.positionMs.toFloat() / resolved.durationMs.toFloat())
.coerceIn(0f, 1f)
// "Watched" = videoId is in our watch history. Keep a full red bar on
// watched videos even once the live resume point is gone, so finished
// videos stay visibly marked (vc=74 — Cobb: "red bar should remain on
// watched videos"). A live resume entry still wins when present (shows
// where you actually left off mid-watch). derivedStateOf isolates each
// row to its own videoId so a new watch doesn't recompose every row's
// bar needlessly.
val watches by com.sulkta.straw.data.History.get().watches.collectAsState()
val watched by remember(videoId) {
derivedStateOf { watches.any { it.videoId == videoId } }
}
val resolved = entry
val fraction: Float = when {
resolved != null && resolved.durationMs > 0L ->
(resolved.positionMs.toFloat() / resolved.durationMs.toFloat()).coerceIn(0f, 1f)
watched -> 1f
else -> return
}
Box(
modifier = Modifier
.align(Alignment.BottomStart)