Stop tracking resume progress for live streams

With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.

Live views are still recorded in history, but carry no resume position:

- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
  stores no position and clears state a drifting HLS duration may have
  left behind. progress is always 0 and toVideo() propagates the flag so
  history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
  storage.
- DataManager routes live saves through recordLiveWatch() and clears the
  flag on the non-live path (before updateProgress, so the auto-finish
  check is not suppressed by the hard-0 live progress), letting an entry
  heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
  and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
  bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
  live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
  resolver carries it with watchedSeconds/isFinished and the sync engine
  applies it when merging into an existing entry. It is read leniently,
  so the schema version stays at 2 and older clients keep parsing these
  records.
This commit is contained in:
Arkadiusz Fal
2026-07-30 23:59:57 +02:00
parent 8faf20c9e9
commit aa6e7fbce9
16 changed files with 392 additions and 28 deletions

View File

@@ -82,10 +82,12 @@ actor CloudKitConflictResolver {
resolved["watchedSeconds"] = localSeconds as CKRecordValue
resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = local["finishedAt"]
resolved["isLive"] = local["isLive"]
} else {
resolved["watchedSeconds"] = serverSeconds as CKRecordValue
resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = server["finishedAt"]
resolved["isLive"] = server["isLive"]
}
// Use most recent updatedAt

View File

@@ -153,6 +153,7 @@ final class CloudKitRecordMapper {
record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue
record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue
record["finishedAt"] = watchEntry.finishedAt as CKRecordValue?
record["isLive"] = (watchEntry.isLive ? 1 : 0) as CKRecordValue
// Timestamps
record["createdAt"] = watchEntry.createdAt as CKRecordValue
@@ -229,6 +230,10 @@ final class CloudKitRecordMapper {
watchEntry.updatedAt = updatedAt
watchEntry.finishedAt = record["finishedAt"] as? Date
// Optional field - records written before this field existed default to false,
// so it is read leniently rather than bumping the schema version.
watchEntry.isLive = (record["isLive"] as? Int64) == 1
return watchEntry
}

View File

@@ -2152,6 +2152,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
existing.watchedSeconds = resolvedEntry.watchedSeconds
existing.isFinished = resolvedEntry.isFinished
existing.finishedAt = resolvedEntry.finishedAt
existing.isLive = resolvedEntry.isLive
existing.updatedAt = resolvedEntry.updatedAt
// Update metadata if newer