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:
parent
6f95b6fa3d
commit
dd2345b1c8
3 changed files with 50 additions and 7 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue