diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 1eb1bd2f8..7e12e200f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -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> = _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() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index c15c21185..6c0332ea1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt index ab0000149..0f1f1586f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt @@ -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)