diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index f9294929..dbbbf99d 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + 37C0AAAA00000000AAAA0003 /* YatteeTopShelf */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */, + ); + path = YatteeTopShelf; + sourceTree = ""; + }; 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 = ""; @@ -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 = ( diff --git a/Yattee/Core/AppGroup.swift b/Yattee/Core/AppGroup.swift new file mode 100644 index 00000000..6563c599 --- /dev/null +++ b/Yattee/Core/AppGroup.swift @@ -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 + } +} diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 5a96fb12..b30aabe2 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -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 diff --git a/Yattee/Core/Settings/SettingsManager+TopShelf.swift b/Yattee/Core/Settings/SettingsManager+TopShelf.swift new file mode 100644 index 00000000..2d1c8358 --- /dev/null +++ b/Yattee/Core/Settings/SettingsManager+TopShelf.swift @@ -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) + } +} diff --git a/Yattee/Core/Settings/TopShelfSection.swift b/Yattee/Core/Settings/TopShelfSection.swift new file mode 100644 index 00000000..b4ed8ec6 --- /dev/null +++ b/Yattee/Core/Settings/TopShelfSection.swift @@ -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] +} diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 043f26d7..ef4e329e 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Data/DataManager+Bookmarks.swift b/Yattee/Data/DataManager+Bookmarks.swift index 0a693dba..518871b9 100644 --- a/Yattee/Data/DataManager+Bookmarks.swift +++ b/Yattee/Data/DataManager+Bookmarks.swift @@ -140,6 +140,7 @@ extension DataManager { modelContext.insert(bookmark) save() } + TopShelfSnapshotWriter.writeBookmarks(dataManager: self) } /// Updates bookmark tags and note for a video. diff --git a/Yattee/Data/DataManager+WatchHistory.swift b/Yattee/Data/DataManager+WatchHistory.swift index cdc96422..35524da9 100644 --- a/Yattee/Data/DataManager+WatchHistory.swift +++ b/Yattee/Data/DataManager+WatchHistory.swift @@ -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. diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index e58bf1be..5dc4eb8d 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index 991b8df7..ea0ed3e4 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -188,6 +188,7 @@ final class SubscriptionFeedCache { category: .general ) saveToDisk() + TopShelfSnapshotWriter.writeFeed() } /// Appends videos to the existing cache (for pagination). diff --git a/Yattee/Services/TopShelfSnapshot.swift b/Yattee/Services/TopShelfSnapshot.swift new file mode 100644 index 00000000..edb7906c --- /dev/null +++ b/Yattee/Services/TopShelfSnapshot.swift @@ -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) + } +} diff --git a/Yattee/Services/TopShelfSnapshotWriter.swift b/Yattee/Services/TopShelfSnapshotWriter.swift new file mode 100644 index 00000000..057de365 --- /dev/null +++ b/Yattee/Services/TopShelfSnapshotWriter.swift @@ -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 diff --git a/Yattee/Views/Settings/SettingsView.swift b/Yattee/Views/Settings/SettingsView.swift index 41ca3250..fa7c1af7 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -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") } diff --git a/Yattee/Views/Settings/TopShelfSettingsView.swift b/Yattee/Views/Settings/TopShelfSettingsView.swift new file mode 100644 index 00000000..cfebc4ed --- /dev/null +++ b/Yattee/Views/Settings/TopShelfSettingsView.swift @@ -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 { + 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 diff --git a/Yattee/Yattee.entitlements b/Yattee/Yattee.entitlements index c97793b7..0f1cea8c 100644 --- a/Yattee/Yattee.entitlements +++ b/Yattee/Yattee.entitlements @@ -14,5 +14,9 @@ com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.application-groups + + group.stream.yattee.app.shared + diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 46e02794..7ea32ef5 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -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))) } diff --git a/YatteeTopShelf/AppGroup.swift b/YatteeTopShelf/AppGroup.swift new file mode 100644 index 00000000..22dc6541 --- /dev/null +++ b/YatteeTopShelf/AppGroup.swift @@ -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 + } +} diff --git a/YatteeTopShelf/Info.plist b/YatteeTopShelf/Info.plist new file mode 100644 index 00000000..4fdb66a4 --- /dev/null +++ b/YatteeTopShelf/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.tv-top-shelf + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).TopShelfContentProvider + + + diff --git a/YatteeTopShelf/TopShelfContentProvider.swift b/YatteeTopShelf/TopShelfContentProvider.swift new file mode 100644 index 00000000..15ace902 --- /dev/null +++ b/YatteeTopShelf/TopShelfContentProvider.swift @@ -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] = 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 + } +} diff --git a/YatteeTopShelf/TopShelfSection.swift b/YatteeTopShelf/TopShelfSection.swift new file mode 100644 index 00000000..fba2b40e --- /dev/null +++ b/YatteeTopShelf/TopShelfSection.swift @@ -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] +} diff --git a/YatteeTopShelf/TopShelfSnapshot.swift b/YatteeTopShelf/TopShelfSnapshot.swift new file mode 100644 index 00000000..0ea28862 --- /dev/null +++ b/YatteeTopShelf/TopShelfSnapshot.swift @@ -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) } + } +} diff --git a/YatteeTopShelf/YatteeTopShelf.entitlements b/YatteeTopShelf/YatteeTopShelf.entitlements new file mode 100644 index 00000000..c60108a2 --- /dev/null +++ b/YatteeTopShelf/YatteeTopShelf.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.stream.yattee.app.shared + + +