mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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.
198 lines
7.2 KiB
Swift
198 lines
7.2 KiB
Swift
//
|
|
// TopShelfSnapshotWriter.swift
|
|
// Yattee
|
|
//
|
|
// Builds Top Shelf snapshots from app data and persists them to the App Group
|
|
// UserDefaults suite so the tvOS Top Shelf extension can read them without
|
|
// touching SwiftData. No-op on non-tvOS platforms.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
enum TopShelfSnapshotWriter {
|
|
#if os(tvOS)
|
|
private static var observers: [NSObjectProtocol] = []
|
|
#endif
|
|
|
|
/// Re-writes all three snapshot sections from current app data.
|
|
/// Call on app launch, after bookmark/watch mutations, and after feed refresh.
|
|
static func writeAll(dataManager: DataManager?, settingsManager: SettingsManager? = nil) {
|
|
#if os(tvOS)
|
|
writeContinueWatching(dataManager: dataManager)
|
|
writeBookmarks(dataManager: dataManager)
|
|
writeFeed()
|
|
if let settingsManager {
|
|
settingsManager.mirrorEnabledSectionsToAppGroup(settingsManager.topShelfSections)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Registers NotificationCenter observers so bookmark/history changes
|
|
/// elsewhere in the app keep the snapshot in sync. Idempotent.
|
|
static func startObserving(dataManager: DataManager?) {
|
|
#if os(tvOS)
|
|
guard observers.isEmpty, let dataManager else { return }
|
|
let center = NotificationCenter.default
|
|
let boxed = WeakDataManagerBox(dataManager)
|
|
observers.append(center.addObserver(
|
|
forName: .bookmarksDidChange, object: nil, queue: .main
|
|
) { _ in
|
|
Task { @MainActor in writeBookmarks(dataManager: boxed.value) }
|
|
})
|
|
observers.append(center.addObserver(
|
|
forName: .watchHistoryDidChange, object: nil, queue: .main
|
|
) { _ in
|
|
Task { @MainActor in writeContinueWatching(dataManager: boxed.value) }
|
|
})
|
|
#endif
|
|
}
|
|
|
|
static func writeContinueWatching(dataManager: DataManager?) {
|
|
#if os(tvOS)
|
|
guard let dataManager else { return }
|
|
let history = dataManager.watchHistory(limit: 50)
|
|
let items = history
|
|
.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
|
|
.prefix(TopShelfSnapshot.maxItems)
|
|
.compactMap(Self.makeItem(from:))
|
|
TopShelfSnapshot.write(Array(items), section: .continueWatching)
|
|
#endif
|
|
}
|
|
|
|
static func writeBookmarks(dataManager: DataManager?) {
|
|
#if os(tvOS)
|
|
guard let dataManager else { return }
|
|
let bookmarks = dataManager.bookmarks(limit: TopShelfSnapshot.maxItems)
|
|
let items = bookmarks.compactMap(Self.makeItem(from:))
|
|
TopShelfSnapshot.write(items, section: .recentBookmarks)
|
|
#endif
|
|
}
|
|
|
|
static func writeFeed() {
|
|
#if os(tvOS)
|
|
let videos = SubscriptionFeedCache.shared.videos.prefix(TopShelfSnapshot.maxItems)
|
|
let items = videos.compactMap(Self.makeItem(from:))
|
|
TopShelfSnapshot.write(items, section: .recentFeed)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Wraps a weak DataManager reference so it can be captured in Sendable closures.
|
|
/// Access is confined to the main actor via the enclosing NotificationCenter queue.
|
|
private final class WeakDataManagerBox: @unchecked Sendable {
|
|
weak var value: DataManager?
|
|
init(_ value: DataManager?) { self.value = value }
|
|
}
|
|
|
|
private extension TopShelfSnapshotWriter {
|
|
static func makeItem(from bookmark: Bookmark) -> TopShelfItem? {
|
|
guard let deepLink = deepLinkURL(
|
|
videoID: bookmark.videoID,
|
|
sourceRawValue: bookmark.sourceRawValue,
|
|
globalProvider: bookmark.globalProvider,
|
|
instanceURLString: bookmark.instanceURLString
|
|
) else { return nil }
|
|
return TopShelfItem(
|
|
videoID: bookmark.videoID,
|
|
title: bookmark.title,
|
|
authorName: bookmark.authorName,
|
|
duration: bookmark.duration,
|
|
thumbnailURL: bookmark.thumbnailURLString,
|
|
deepLinkURL: deepLink,
|
|
progressSeconds: nil
|
|
)
|
|
}
|
|
|
|
static func makeItem(from entry: WatchEntry) -> TopShelfItem? {
|
|
guard let deepLink = deepLinkURL(
|
|
videoID: entry.videoID,
|
|
sourceRawValue: entry.sourceRawValue,
|
|
globalProvider: entry.globalProvider,
|
|
instanceURLString: entry.instanceURLString
|
|
) else { return nil }
|
|
return TopShelfItem(
|
|
videoID: entry.videoID,
|
|
title: entry.title,
|
|
authorName: entry.authorName,
|
|
duration: entry.duration,
|
|
thumbnailURL: entry.thumbnailURLString,
|
|
deepLinkURL: deepLink,
|
|
progressSeconds: entry.watchedSeconds
|
|
)
|
|
}
|
|
|
|
static func makeItem(from video: Video) -> TopShelfItem? {
|
|
guard let deepLink = deepLinkURL(for: video.id) else { return nil }
|
|
let thumbnail = bestThumbnailURL(from: video.thumbnails)
|
|
return TopShelfItem(
|
|
videoID: video.id.videoID,
|
|
title: video.title,
|
|
authorName: video.author.name,
|
|
duration: video.duration,
|
|
thumbnailURL: thumbnail,
|
|
deepLinkURL: deepLink,
|
|
progressSeconds: nil
|
|
)
|
|
}
|
|
|
|
/// Builds a `yattee://video/...` deep link from a Video's ID.
|
|
/// Returns nil for source types not round-trippable via the URL scheme (e.g. extracted).
|
|
static func deepLinkURL(for videoID: VideoID) -> String? {
|
|
switch videoID.source {
|
|
case .global:
|
|
return "yattee://video/\(videoID.videoID)"
|
|
case .federated(_, let instance):
|
|
var components = URLComponents()
|
|
components.scheme = "yattee"
|
|
components.host = "video"
|
|
components.path = "/\(videoID.videoID)"
|
|
components.queryItems = [
|
|
URLQueryItem(name: "source", value: "peertube"),
|
|
URLQueryItem(name: "instance", value: instance.absoluteString)
|
|
]
|
|
return components.url?.absoluteString
|
|
case .extracted:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Builds a deep link from the stored Bookmark/WatchEntry source fields.
|
|
static func deepLinkURL(
|
|
videoID: String,
|
|
sourceRawValue: String,
|
|
globalProvider: String?,
|
|
instanceURLString: String?
|
|
) -> String? {
|
|
switch sourceRawValue {
|
|
case "global":
|
|
// Only YouTube survives the round-trip; other global providers fall through.
|
|
return "yattee://video/\(videoID)"
|
|
case "federated":
|
|
guard globalProvider == "peertube",
|
|
let urlStr = instanceURLString,
|
|
let instanceURL = URL(string: urlStr) else {
|
|
return nil
|
|
}
|
|
var components = URLComponents()
|
|
components.scheme = "yattee"
|
|
components.host = "video"
|
|
components.path = "/\(videoID)"
|
|
components.queryItems = [
|
|
URLQueryItem(name: "source", value: "peertube"),
|
|
URLQueryItem(name: "instance", value: instanceURL.absoluteString)
|
|
]
|
|
return components.url?.absoluteString
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func bestThumbnailURL(from thumbnails: [Thumbnail]) -> String? {
|
|
thumbnails.max(by: { $0.quality < $1.quality })?.url.absoluteString
|
|
}
|
|
}
|
|
#endif
|