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:
Arkadiusz Fal
2026-04-17 18:31:09 +02:00
parent 90c88728c4
commit 9f86ff0667
22 changed files with 821 additions and 21 deletions

View File

@@ -76,6 +76,13 @@ struct YatteeApp: App {
}
.onAppear {
registerBackgroundTasksIfNeeded()
#if os(tvOS)
TopShelfSnapshotWriter.startObserving(dataManager: appEnvironment.dataManager)
TopShelfSnapshotWriter.writeAll(
dataManager: appEnvironment.dataManager,
settingsManager: appEnvironment.settingsManager
)
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .continueUserActivity)) { notification in
if let activity = notification.object as? NSUserActivity {
@@ -373,15 +380,51 @@ struct YatteeApp: App {
/// Play a video from a deep link.
private func playVideoFromDeepLink(videoID: VideoID) async {
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { return }
LoggingService.shared.info(
"Deep link play: videoID=\(videoID.videoID) source=\(videoID.source)",
category: .general
)
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
LoggingService.shared.warning(
"Deep link play: no instance configured for source \(videoID.source) — aborting",
category: .general
)
appEnvironment.toastManager.showError(
String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"),
subtitle: String(
localized: "deepLink.noInstance.subtitle",
defaultValue: "No source is configured for this video."
)
)
return
}
LoggingService.shared.info(
"Deep link play: using instance \(instance.url.absoluteString)",
category: .general
)
let toastID = appEnvironment.toastManager.show(
scopes: [.main, .player],
category: .loading,
title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
subtitle: instance.name,
autoDismissDelay: 30.0
)
do {
let video = try await appEnvironment.contentService.video(
id: videoID.videoID,
instance: instance
)
LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general)
appEnvironment.toastManager.dismiss(id: toastID)
appEnvironment.playerService.openVideo(video)
} catch {
LoggingService.shared.error(
"Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",
category: .general
)
appEnvironment.toastManager.dismiss(id: toastID)
// If video fetch fails, fall back to navigating to the video info view
appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
}