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,10 @@
import Foundation
enum AppGroup {
static let identifier = "group.stream.yattee.app.shared"
static let enabledSectionsKey = "topShelf.enabledSections"
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

13
YatteeTopShelf/Info.plist Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.tv-top-shelf</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).TopShelfContentProvider</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,38 @@
import TVServices
class TopShelfContentProvider: TVTopShelfContentProvider {
override func loadTopShelfContent(completionHandler: @escaping (any TVTopShelfContent) -> Void) {
let defaults = AppGroup.defaults
let enabled = TopShelfSnapshot.enabledSections(from: defaults)
let collections: [TVTopShelfItemCollection<TVTopShelfSectionedItem>] = enabled.compactMap { section in
let items = TopShelfSnapshot.read(section: section, from: defaults)
guard !items.isEmpty else { return nil }
let sectioned = items.map { makeItem(from: $0, in: section) }
let collection = TVTopShelfItemCollection(items: sectioned)
collection.title = section.localizedTitle
return collection
}
completionHandler(TVTopShelfSectionedContent(sections: collections))
}
private func makeItem(from item: TopShelfItem, in section: TopShelfSection) -> TVTopShelfSectionedItem {
let sectioned = TVTopShelfSectionedItem(identifier: "\(section.rawValue).\(item.videoID)")
sectioned.title = item.title
sectioned.imageShape = .hdtv
if let url = item.thumbnailURL.flatMap(URL.init(string:)) {
sectioned.setImageURL(url, for: .screenScale1x)
sectioned.setImageURL(url, for: .screenScale2x)
}
if let deepLink = URL(string: item.deepLinkURL) {
sectioned.displayAction = TVTopShelfAction(url: deepLink)
sectioned.playAction = TVTopShelfAction(url: deepLink)
}
if section == .continueWatching,
let progress = item.progressSeconds, item.duration > 0 {
sectioned.playbackProgress = max(0, min(1, progress / item.duration))
}
return sectioned
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
// Must stay in sync with Yattee/Core/Settings/TopShelfSection.swift.
enum TopShelfSection: String, Codable, CaseIterable, Sendable {
case continueWatching
case recentFeed
case recentBookmarks
var localizedTitle: String {
// The extension doesn't share the main app's string catalog, so we
// ship English fallbacks here. Keep in sync with
// Localizable.xcstrings entries of the same keys.
switch self {
case .continueWatching:
return String(localized: "home.section.continueWatching", defaultValue: "Continue Watching")
case .recentFeed:
return String(localized: "home.section.feed", defaultValue: "Feed")
case .recentBookmarks:
return String(localized: "home.section.bookmarks", defaultValue: "Bookmarks")
}
}
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

@@ -0,0 +1,29 @@
import Foundation
// Must stay in sync with Yattee/Services/TopShelfSnapshot.swift.
struct TopShelfItem: Codable, Hashable, Sendable {
let videoID: String
let title: String
let authorName: String
let duration: TimeInterval
let thumbnailURL: String?
let deepLinkURL: String
let progressSeconds: TimeInterval?
}
enum TopShelfSnapshot {
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 enabledSections(from defaults: UserDefaults = AppGroup.defaults) -> [TopShelfSection] {
guard let raw = defaults.array(forKey: AppGroup.enabledSectionsKey) as? [String] else {
return TopShelfSection.defaultOrder
}
return raw.compactMap { TopShelfSection(rawValue: $0) }
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.stream.yattee.app.shared</string>
</array>
</dict>
</plist>