mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ enum TopShelfSnapshotWriter {
|
||||
guard let dataManager else { return }
|
||||
let history = dataManager.watchHistory(limit: 50)
|
||||
let items = history
|
||||
.filter { !$0.isFinished && $0.watchedSeconds > 10 }
|
||||
.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
|
||||
.prefix(TopShelfSnapshot.maxItems)
|
||||
.compactMap(Self.makeItem(from:))
|
||||
TopShelfSnapshot.write(Array(items), section: .continueWatching)
|
||||
|
||||
Reference in New Issue
Block a user