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

@@ -420,7 +420,11 @@ final class PlayerService {
let savedProgress = dataManager.watchProgress(for: video.id.videoID)
LoggingService.shared.logPlayer("Replay check: savedProgress=\(savedProgress ?? -1), startTime=\(startTime ?? -1), duration=\(video.duration), threshold=\(completionThreshold)")
if let startTime {
if video.isLive || selectedStream.isLive {
// Live playback positions are relative to the live edge, so a stored
// or passed position means nothing - always start at the live edge.
seekTime = 0
} else if let startTime {
// Explicit startTime provided - use it (0 means play from beginning, >0 means resume)
// For quality switching with startTime > 0, honor the time unless video was completed
if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold {
@@ -1028,6 +1032,9 @@ final class PlayerService {
/// - video: The video to open
/// - startTime: Optional start time in seconds (used for continue watching)
func openVideo(_ video: Video, startTime: TimeInterval? = nil) {
// Live streams have no meaningful resume position - drop any passed one
// so callers with a stale watch entry cannot seek into the live window.
let startTime = video.isLive ? nil : startTime
// Check if MPV PiP is active - if so, don't expand the player
#if os(iOS) || os(macOS)
@@ -2797,8 +2804,9 @@ final class PlayerService {
guard let video = state.currentVideo,
state.currentTime > 0 else { return }
// Save locally only during playback - no iCloud sync overhead
dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration)
// Save locally only during playback - no iCloud sync overhead.
// Live streams still get a history entry, but no resume position.
dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive)
// Update Handoff activity with current playback time
handoffManager?.updatePlaybackTime(state.currentTime)
@@ -2811,6 +2819,10 @@ final class PlayerService {
settingsManager?.saveWatchHistory != false,
let video = state.currentVideo else { return }
// A live stream never "completes" - saveProgress() has already recorded
// its history entry, and writing a duration here would fabricate progress.
guard !state.isLive else { return }
// Use video.duration (API-reported) to match WatchEntry.duration stored value.
// This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration.
// Fall back to state.duration (MPV-reported) if video.duration is not available.
@@ -2836,7 +2848,7 @@ final class PlayerService {
state.currentTime > 0 else { return }
// Save and queue for iCloud sync (used when video closes/switches)
dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration)
dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
}