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

@@ -13,8 +13,13 @@ extension DataManager {
/// Records or updates watch progress locally without triggering iCloud sync.
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
///
/// - Parameter isLive: Set by the player when the active stream is live but the
/// `Video` metadata does not say so (some sources hardcode `isLive: false`).
/// Live views are still recorded in history, but without a resume position.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -22,13 +27,24 @@ extension DataManager {
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
save()
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
save()
@@ -43,9 +59,10 @@ extension DataManager {
/// Records or updates watch progress for a video and queues for iCloud sync.
/// Use this when video closes or switches to sync the final progress.
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
// Find existing entry or create new one
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -54,13 +71,24 @@ extension DataManager {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
entry = newEntry