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

@@ -51,6 +51,10 @@ final class WatchEntry {
/// Thumbnail URL string.
var thumbnailURLString: String?
/// Whether this entry was recorded for a live stream.
/// Live entries never carry a resume position - see `recordLiveWatch()`.
var isLive: Bool = false
// MARK: - Watch Progress
/// Last watched position in seconds.
@@ -86,7 +90,8 @@ final class WatchEntry {
duration: TimeInterval,
thumbnailURLString: String? = nil,
watchedSeconds: TimeInterval = 0,
isFinished: Bool = false
isFinished: Bool = false,
isLive: Bool = false
) {
self.videoID = videoID
self.sourceRawValue = sourceRawValue
@@ -102,6 +107,7 @@ final class WatchEntry {
self.thumbnailURLString = thumbnailURLString
self.watchedSeconds = watchedSeconds
self.isFinished = isFinished
self.isLive = isLive
self.createdAt = Date()
self.updatedAt = Date()
}
@@ -136,8 +142,10 @@ final class WatchEntry {
}
/// Watch progress as a percentage (0.0 to 1.0).
/// Always 0 for live streams - their playback position is relative to the
/// live edge, so it does not describe progress through a fixed timeline.
var progress: Double {
guard duration > 0 else { return 0 }
guard !isLive, duration > 0 else { return 0 }
return min(watchedSeconds / duration, 1.0)
}
@@ -188,6 +196,20 @@ final class WatchEntry {
}
}
/// Records a live-stream view without storing a resume position.
/// Live playback positions are relative to the live edge, so keeping them
/// would make the app offer to "continue" from a meaningless timestamp.
/// Also clears state a previous non-live save may have left behind, which
/// heals entries wrongly marked finished by a drifting HLS duration.
func recordLiveWatch() {
isLive = true
watchedSeconds = 0
duration = 0
isFinished = false
finishedAt = nil
updatedAt = Date()
}
/// Marks the video as finished.
func markAsFinished() {
isFinished = true
@@ -220,7 +242,7 @@ extension WatchEntry {
viewCount: nil,
likeCount: nil,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil
)
@@ -260,8 +282,11 @@ extension WatchEntry {
title: video.title,
authorName: video.author.name,
authorID: video.author.id,
duration: video.duration,
thumbnailURLString: video.bestThumbnail?.url.absoluteString
// Live videos report no usable duration - Invidious sends 0 and Piped
// uses -1 as its live sentinel, which must never reach storage.
duration: video.isLive ? 0 : max(0, video.duration),
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
isLive: video.isLive
)
}
}