mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
Add tvOS Top Shelf extension
Surfaces Continue Watching, Recent Feed, and Recent Bookmarks in the
Apple TV Home top shelf when Yattee is focused. Tapping a tile opens
the video via the existing yattee://video/{id} deep link.
- New YatteeTopShelf app extension target (tvOS only). LD_ENTRY_POINT is
overridden to _NSExtensionMain; the tv-app-extension product type
defaults to _TVExtensionMain which is for the pre-tvOS-13 legacy API
and crashes modern TVTopShelfContentProvider subclasses at launch.
- Main app writes per-section JSON snapshots (capped at 10 items each)
to a shared App Group UserDefaults suite after bookmark, watch-history,
and feed-cache changes, plus an initial write on launch.
- Enabled-sections list is mirrored to the same App Group so the
extension can respect the user's selection without touching SwiftData.
- Settings → Top Shelf (tvOS only) lets the user toggle sections.
- Deep link playback shows a loading toast while video details are
fetched, and an error toast if no source is configured.
This commit is contained in:
@@ -188,6 +188,7 @@ final class SubscriptionFeedCache {
|
||||
category: .general
|
||||
)
|
||||
saveToDisk()
|
||||
TopShelfSnapshotWriter.writeFeed()
|
||||
}
|
||||
|
||||
/// Appends videos to the existing cache (for pagination).
|
||||
|
||||
46
Yattee/Services/TopShelfSnapshot.swift
Normal file
46
Yattee/Services/TopShelfSnapshot.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// TopShelfSnapshot.swift
|
||||
// Yattee
|
||||
//
|
||||
// Shared data contract between the main app (writer) and YatteeTopShelf extension (reader).
|
||||
// Items are persisted as JSON in the App Group UserDefaults suite under per-section keys.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A compact representation of a video suitable for a tvOS Top Shelf row.
|
||||
/// Kept deliberately small — the extension has a tight memory/work budget.
|
||||
struct TopShelfItem: Codable, Hashable, Sendable {
|
||||
let videoID: String
|
||||
let title: String
|
||||
let authorName: String
|
||||
let duration: TimeInterval
|
||||
let thumbnailURL: String?
|
||||
/// Pre-built `yattee://video/...` URL the extension uses for `displayURL`.
|
||||
let deepLinkURL: String
|
||||
/// Seconds watched — only set for continue-watching items.
|
||||
let progressSeconds: TimeInterval?
|
||||
}
|
||||
|
||||
/// Max items retained per section snapshot. The extension only needs a handful.
|
||||
enum TopShelfSnapshot {
|
||||
static let maxItems = 10
|
||||
|
||||
static func read(section: TopShelfSection, from defaults: UserDefaults = AppGroup.defaults) -> [TopShelfItem] {
|
||||
guard let data = defaults.data(forKey: section.snapshotKey),
|
||||
let items = try? JSONDecoder().decode([TopShelfItem].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
static func write(_ items: [TopShelfItem], section: TopShelfSection, to defaults: UserDefaults = AppGroup.defaults) {
|
||||
let capped = Array(items.prefix(maxItems))
|
||||
if capped.isEmpty {
|
||||
defaults.removeObject(forKey: section.snapshotKey)
|
||||
return
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(capped) else { return }
|
||||
defaults.set(data, forKey: section.snapshotKey)
|
||||
}
|
||||
}
|
||||
197
Yattee/Services/TopShelfSnapshotWriter.swift
Normal file
197
Yattee/Services/TopShelfSnapshotWriter.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// 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.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
|
||||
Reference in New Issue
Block a user