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
c4c871d906
commit
f919a914ff
3 changed files with 50 additions and 7 deletions
|
|
@ -26,6 +26,10 @@ data class WatchHistoryItem(
|
||||||
val uploader: String,
|
val uploader: String,
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
val watchedAt: Long,
|
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"
|
private const val PREFS = "straw_history"
|
||||||
|
|
@ -62,13 +66,18 @@ class HistoryStore(context: Context) {
|
||||||
val searches: StateFlow<List<String>> = _searches.asStateFlow()
|
val searches: StateFlow<List<String>> = _searches.asStateFlow()
|
||||||
|
|
||||||
fun recordWatch(item: WatchHistoryItem) {
|
fun recordWatch(item: WatchHistoryItem) {
|
||||||
val now = item.copy(watchedAt = System.currentTimeMillis())
|
val ts = System.currentTimeMillis()
|
||||||
// Atomic read-modify-write — two concurrent recordWatch calls
|
// Atomic read-modify-write — two concurrent recordWatch calls
|
||||||
// both reading the same `current` and one clobbering the other
|
// 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 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 }
|
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()
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
// Action bar — uniform tonal pills in a single horizontally
|
// Action bar — uniform tonal pills in a single horizontally
|
||||||
// scrollable row so they never wrap into a ragged block. The
|
// 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) {
|
val entry by remember(videoId) {
|
||||||
derivedStateOf { positions[videoId] }
|
derivedStateOf { positions[videoId] }
|
||||||
}
|
}
|
||||||
val resolved = entry ?: return
|
// "Watched" = videoId is in our watch history. Keep a full red bar on
|
||||||
if (resolved.durationMs <= 0L) return
|
// watched videos even once the live resume point is gone, so finished
|
||||||
val fraction = (resolved.positionMs.toFloat() / resolved.durationMs.toFloat())
|
// videos stay visibly marked (vc=74 — a tester: "red bar should remain on
|
||||||
.coerceIn(0f, 1f)
|
// 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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue