mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 19:05:03 +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:
10
YatteeTopShelf/AppGroup.swift
Normal file
10
YatteeTopShelf/AppGroup.swift
Normal 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
13
YatteeTopShelf/Info.plist
Normal 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>
|
||||
38
YatteeTopShelf/TopShelfContentProvider.swift
Normal file
38
YatteeTopShelf/TopShelfContentProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
YatteeTopShelf/TopShelfSection.swift
Normal file
32
YatteeTopShelf/TopShelfSection.swift
Normal 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]
|
||||
}
|
||||
29
YatteeTopShelf/TopShelfSnapshot.swift
Normal file
29
YatteeTopShelf/TopShelfSnapshot.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
10
YatteeTopShelf/YatteeTopShelf.entitlements
Normal file
10
YatteeTopShelf/YatteeTopShelf.entitlements
Normal 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>
|
||||
Reference in New Issue
Block a user