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

@@ -0,0 +1,12 @@
import Foundation
enum AppGroup {
static let identifier = "group.stream.yattee.app.shared"
/// UserDefaults key holding an ordered [String] of enabled TopShelfSection raw values.
static let enabledSectionsKey = "topShelf.enabledSections"
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

View File

@@ -65,6 +65,9 @@ enum SettingsKey: String, CaseIterable {
case homeSectionItemsLimit
case homeSectionLayout
// Top Shelf (tvOS)
case topShelfSections
// Tab Bar (compact size class)
case tabBarItemOrder
case tabBarItemVisibility
@@ -126,6 +129,8 @@ enum SettingsKey: String, CaseIterable {
// Home layout different UI paradigms per platform
.homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout,
.homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout,
// Top Shelf tvOS only
.topShelfSections,
// Tab bar (compact size class) layout
.tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab,
// Sidebar layout/selection

View File

@@ -0,0 +1,43 @@
//
// SettingsManager+TopShelf.swift
// Yattee
//
// tvOS Top Shelf settings.
//
import Foundation
extension SettingsManager {
/// Ordered list of sections visible in the tvOS Top Shelf.
/// Inclusion means the section is shown; absence hides it.
var topShelfSections: [TopShelfSection] {
get {
if let cached = _topShelfSections { return cached }
guard let data = data(for: .topShelfSections),
let saved = try? JSONDecoder().decode([TopShelfSection].self, from: data) else {
return TopShelfSection.defaultOrder
}
return saved
}
set {
_topShelfSections = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .topShelfSections)
}
let tsKey = modifiedAtKey(for: .topShelfSections)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
mirrorEnabledSectionsToAppGroup(newValue)
}
}
/// Mirrors the enabled-sections list to the App Group UserDefaults suite
/// so the tvOS Top Shelf extension can read the user's selection.
func mirrorEnabledSectionsToAppGroup(_ sections: [TopShelfSection]) {
let rawValues = sections.map(\.rawValue)
AppGroup.defaults.set(rawValues, forKey: AppGroup.enabledSectionsKey)
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
/// Sections that can appear in the tvOS Top Shelf.
/// Stored ordered in `SettingsKey.topShelfSections` inclusion = visible.
enum TopShelfSection: String, Codable, CaseIterable, Identifiable, Sendable {
case continueWatching
case recentFeed
case recentBookmarks
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .continueWatching: return String(localized: "home.section.continueWatching")
case .recentFeed: return String(localized: "home.section.feed")
case .recentBookmarks: return String(localized: "home.section.bookmarks")
}
}
/// UserDefaults key (under the app-group suite) holding the JSON snapshot for this section.
var snapshotKey: String {
switch self {
case .continueWatching: return "topShelf.continueWatching"
case .recentFeed: return "topShelf.recentFeed"
case .recentBookmarks: return "topShelf.recentBookmarks"
}
}
static let defaultOrder: [TopShelfSection] = [.continueWatching, .recentFeed, .recentBookmarks]
}

View File

@@ -109,6 +109,9 @@ final class SettingsManager {
var _homeSectionItemsLimit: Int?
var _homeSectionLayout: HomeSectionLayout?
// Top Shelf (tvOS)
var _topShelfSections: [TopShelfSection]?
// Tab bar settings (compact size class only - iOS)
var _tabBarItemOrder: [TabBarItem]?
var _tabBarItemVisibility: [TabBarItem: Bool]?
@@ -453,6 +456,7 @@ final class SettingsManager {
_homeSectionVisibility = nil
_homeSectionItemsLimit = nil
_homeSectionLayout = nil
_topShelfSections = nil
_tabBarItemOrder = nil
_tabBarItemVisibility = nil
_sidebarMainItemOrder = nil

View File

@@ -140,6 +140,7 @@ extension DataManager {
modelContext.insert(bookmark)
save()
}
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
}
/// Updates bookmark tags and note for a video.

View File

@@ -69,6 +69,7 @@ extension DataManager {
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
}
@@ -148,6 +149,7 @@ extension DataManager {
modelContext.insert(watchEntry)
save()
}
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Clears all watch history.

View File

@@ -1873,6 +1873,39 @@
}
}
},
"deepLink.loading.title" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Loading video…"
}
}
}
},
"deepLink.noInstance.subtitle" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "No source is configured for this video."
}
}
}
},
"deepLink.noInstance.title" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Can't open video"
}
}
}
},
"discovery.empty.description" : {
"comment" : "Description when no network shares are found",
"localizations" : {
@@ -4953,26 +4986,6 @@
}
}
},
"mediaBrowser.unsupportedFile.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unsupported file"
}
}
}
},
"mediaBrowser.unsupportedFile.message %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ cannot be played. This file type is not supported."
}
}
}
},
"mediaBrowser.sort.dateCreated" : {
"localizations" : {
"en" : {
@@ -5003,6 +5016,26 @@
}
}
},
"mediaBrowser.unsupportedFile.message %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ cannot be played. This file type is not supported."
}
}
}
},
"mediaBrowser.unsupportedFile.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unsupported file"
}
}
}
},
"mediaBrowser.viewOptions.ascending" : {
"localizations" : {
"en" : {
@@ -14140,6 +14173,39 @@
}
}
},
"settings.topShelf.sections.footer" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Enabled sections appear in the Apple TV Home top shelf when Yattee is focused."
}
}
}
},
"settings.topShelf.sections.header" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Sections"
}
}
}
},
"settings.topShelf.title" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Top Shelf"
}
}
}
},
"settings.translators.empty" : {
"localizations" : {
"en" : {

View File

@@ -188,6 +188,7 @@ final class SubscriptionFeedCache {
category: .general
)
saveToDisk()
TopShelfSnapshotWriter.writeFeed()
}
/// Appends videos to the existing cache (for pagination).

View 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)
}
}

View 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

View File

@@ -131,6 +131,10 @@ struct SettingsView: View {
Label(String(localized: "settings.layoutNavigation.title"), systemImage: "hand.tap")
}
NavigationLink { TVSidebarDetailContainer(systemImage: "rectangle.on.rectangle.angled", title: String(localized: "settings.topShelf.title", defaultValue: "Top Shelf")) { TopShelfSettingsView() } } label: {
Label(String(localized: "settings.topShelf.title", defaultValue: "Top Shelf"), systemImage: "rectangle.on.rectangle.angled")
}
NavigationLink { TVSidebarDetailContainer(systemImage: "play.circle", title: String(localized: "settings.playback.sectionTitle")) { PlaybackSettingsView() } } label: {
Label(String(localized: "settings.playback.sectionTitle"), systemImage: "play.circle")
}

View File

@@ -0,0 +1,52 @@
//
// TopShelfSettingsView.swift
// Yattee
//
// tvOS-only Top Shelf configuration.
//
#if os(tvOS)
import SwiftUI
struct TopShelfSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
Form {
if let settings = appEnvironment?.settingsManager {
Section {
ForEach(TopShelfSection.allCases) { section in
Toggle(section.localizedTitle, isOn: binding(for: section, settings: settings))
}
} header: {
Text(String(localized: "settings.topShelf.sections.header", defaultValue: "Sections"))
} footer: {
Text(String(
localized: "settings.topShelf.sections.footer",
defaultValue: "Enabled sections appear in the Apple TV Home top shelf when Yattee is focused."
))
}
}
}
}
private func binding(for section: TopShelfSection, settings: SettingsManager) -> Binding<Bool> {
Binding(
get: { settings.topShelfSections.contains(section) },
set: { enabled in
var sections = settings.topShelfSections
if enabled {
if !sections.contains(section) {
let defaultIndex = TopShelfSection.defaultOrder.firstIndex(of: section) ?? sections.endIndex
let insertAt = min(defaultIndex, sections.count)
sections.insert(section, at: insertAt)
}
} else {
sections.removeAll { $0 == section }
}
settings.topShelfSections = sections
}
)
}
}
#endif

View File

@@ -14,5 +14,9 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.stream.yattee.app.shared</string>
</array>
</dict>
</plist>

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)))
}