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