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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user