mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +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:
@@ -13,6 +13,7 @@
|
||||
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF3002EF21783002C1CD7 /* MPVKit-GPL */; };
|
||||
37BA19A62EE4DFEE001D7B0F /* Yattee2.icon in Resources */ = {isa = PBXBuildFile; fileRef = 37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */; };
|
||||
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -23,6 +24,13 @@
|
||||
remoteGlobalIDString = 37BA19AA2EE4EB7F001D7B0F;
|
||||
remoteInfo = YatteeShareExtension;
|
||||
};
|
||||
37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 37C0AAAA00000000AAAA0001;
|
||||
remoteInfo = YatteeTopShelf;
|
||||
};
|
||||
37D0B2982EDB23BD00B9C4ED /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
|
||||
@@ -40,6 +48,7 @@
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */,
|
||||
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -50,6 +59,7 @@
|
||||
372D1A272EDB163800F58F7A /* Yattee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Yattee.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Yattee2.icon; sourceTree = "<group>"; };
|
||||
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeTopShelf.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = YatteeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -68,6 +78,13 @@
|
||||
);
|
||||
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
|
||||
};
|
||||
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -87,6 +104,14 @@
|
||||
path = YatteeShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */,
|
||||
);
|
||||
path = YatteeTopShelf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
37D0B2952EDB23BD00B9C4ED /* YatteeTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = YatteeTests;
|
||||
@@ -113,6 +138,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2912EDB23BD00B9C4ED /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -129,6 +161,7 @@
|
||||
372D1A292EDB163800F58F7A /* Yattee */,
|
||||
37D0B2952EDB23BD00B9C4ED /* YatteeTests */,
|
||||
37BA19AC2EE4EB7F001D7B0F /* YatteeShareExtension */,
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
|
||||
372D1A282EDB163800F58F7A /* Products */,
|
||||
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */,
|
||||
);
|
||||
@@ -140,6 +173,7 @@
|
||||
372D1A272EDB163800F58F7A /* Yattee.app */,
|
||||
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */,
|
||||
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */,
|
||||
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -160,6 +194,7 @@
|
||||
);
|
||||
dependencies = (
|
||||
37BA19B42EE4EB7F001D7B0F /* PBXTargetDependency */,
|
||||
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
372D1A292EDB163800F58F7A /* Yattee */,
|
||||
@@ -197,6 +232,28 @@
|
||||
productReference = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */;
|
||||
buildPhases = (
|
||||
37C0AAAA00000000AAAA0004 /* Sources */,
|
||||
37C0AAAA00000000AAAA0005 /* Frameworks */,
|
||||
37C0AAAA00000000AAAA0006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
|
||||
);
|
||||
name = YatteeTopShelf;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = YatteeTopShelf;
|
||||
productReference = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */;
|
||||
productType = "com.apple.product-type.tv-app-extension";
|
||||
};
|
||||
37D0B2932EDB23BD00B9C4ED /* YatteeTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 37D0B29A2EDB23BD00B9C4ED /* Build configuration list for PBXNativeTarget "YatteeTests" */;
|
||||
@@ -236,6 +293,9 @@
|
||||
37BA19AA2EE4EB7F001D7B0F = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
37C0AAAA00000000AAAA0001 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
37D0B2932EDB23BD00B9C4ED = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
TestTargetID = 372D1A262EDB163800F58F7A;
|
||||
@@ -263,6 +323,7 @@
|
||||
372D1A262EDB163800F58F7A /* Yattee */,
|
||||
37D0B2932EDB23BD00B9C4ED /* YatteeTests */,
|
||||
37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */,
|
||||
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -283,6 +344,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2922EDB23BD00B9C4ED /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -307,6 +375,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37C0AAAA00000000AAAA0004 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
37D0B2902EDB23BD00B9C4ED /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -323,6 +398,11 @@
|
||||
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
|
||||
targetProxy = 37BA19B32EE4EB7F001D7B0F /* PBXContainerItemProxy */;
|
||||
};
|
||||
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
|
||||
targetProxy = 37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */;
|
||||
};
|
||||
37D0B2992EDB23BD00B9C4ED /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 372D1A262EDB163800F58F7A /* Yattee */;
|
||||
@@ -674,6 +754,75 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
37C0AAAA00000000AAAA000A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 256;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
LD_ENTRY_POINT = _NSExtensionMain;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
37C0AAAA00000000AAAA000B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 256;
|
||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
LD_ENTRY_POINT = _NSExtensionMain;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
37D0B29B2EDB23BD00B9C4ED /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -762,6 +911,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
37C0AAAA00000000AAAA000A /* Debug */,
|
||||
37C0AAAA00000000AAAA000B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
37D0B29A2EDB23BD00B9C4ED /* Build configuration list for PBXNativeTarget "YatteeTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
12
Yattee/Core/AppGroup.swift
Normal file
12
Yattee/Core/AppGroup.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
43
Yattee/Core/Settings/SettingsManager+TopShelf.swift
Normal file
43
Yattee/Core/Settings/SettingsManager+TopShelf.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
30
Yattee/Core/Settings/TopShelfSection.swift
Normal file
30
Yattee/Core/Settings/TopShelfSection.swift
Normal 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]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -140,6 +140,7 @@ extension DataManager {
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
}
|
||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||
}
|
||||
|
||||
/// Updates bookmark tags and note for a video.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -188,6 +188,7 @@ final class SubscriptionFeedCache {
|
||||
category: .general
|
||||
)
|
||||
saveToDisk()
|
||||
TopShelfSnapshotWriter.writeFeed()
|
||||
}
|
||||
|
||||
/// Appends videos to the existing cache (for pagination).
|
||||
|
||||
46
Yattee/Services/TopShelfSnapshot.swift
Normal file
46
Yattee/Services/TopShelfSnapshot.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
197
Yattee/Services/TopShelfSnapshotWriter.swift
Normal file
197
Yattee/Services/TopShelfSnapshotWriter.swift
Normal 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
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
52
Yattee/Views/Settings/TopShelfSettingsView.swift
Normal file
52
Yattee/Views/Settings/TopShelfSettingsView.swift
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
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