diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c7b1b667..9953be38 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -61,7 +61,7 @@ import '../utils/video_player_navigation.dart'; import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; -import '../services/watch_next_service.dart'; +import '../services/system_shelf_service.dart'; import 'auth_screen.dart'; import 'libraries/content_state_builder.dart'; import 'main_screen.dart'; @@ -769,10 +769,7 @@ class _DiscoverScreenState extends State _heroFocusNode.requestFocus(); } - // Sync to Android TV Watch Next row - if (Platform.isAndroid) { - unawaited(_syncWatchNext(onDeck)); - } + unawaited(_syncSystemShelf(onDeck)); // Sync PageController to first page after OnDeck loads if (_heroController.hasClients && onDeck.isNotEmpty) { @@ -875,10 +872,7 @@ class _DiscoverScreenState extends State } }); - // Sync to Android TV Watch Next row - if (Platform.isAndroid) { - unawaited(_syncWatchNext(onDeck)); - } + unawaited(_syncSystemShelf(onDeck)); appLogger.d('Continue Watching refreshed successfully'); } @@ -901,16 +895,16 @@ class _DiscoverScreenState extends State ); } - /// Sync On Deck items to Android TV Watch Next row. - Future _syncWatchNext(List onDeck) async { + /// Sync Continue Watching items to the platform launcher shelf. + Future _syncSystemShelf(List onDeck) async { try { - await WatchNextService().syncFromOnDeck( + await SystemShelfService().syncFromContinueWatching( onDeck, (serverId) => context.getMediaClientWithFallback(serverId), hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), ); } catch (e) { - appLogger.w('Failed to sync Watch Next', error: e); + appLogger.w('Failed to sync system shelf', error: e); } } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3e03c981..d8a9e42e 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -55,7 +55,7 @@ import 'search_screen.dart'; import 'downloads/downloads_screen.dart'; import 'settings/settings_screen.dart'; import 'profile/profile_switch_screen.dart'; -import '../services/watch_next_service.dart'; +import '../services/system_shelf_service.dart'; import '../watch_together/watch_together.dart'; /// Provides access to the main screen's focus control. @@ -334,7 +334,7 @@ class _MainScreenState extends State // Set up Watch Together callbacks immediately (must be synchronous to catch early messages) if (!_isOffline) { _setupWatchTogetherCallback(); - _setupWatchNextDeepLink(); + _setupSystemShelfDeepLink(); } // Wire profile binder + tracker bootstrap (skip in offline mode) @@ -646,35 +646,35 @@ class _MainScreenState extends State } } - /// Set up Watch Next deep link handling for Android TV launcher taps - void _setupWatchNextDeepLink() { - if (!Platform.isAndroid) return; + /// Set up launcher shelf deep link handling for Android TV and tvOS taps. + void _setupSystemShelfDeepLink() { + if (!Platform.isAndroid && !PlatformDetector.isAppleTV()) return; - final watchNext = WatchNextService(); + final systemShelf = SystemShelfService(); // Listen for deep links when app is already running (warm start) - watchNext.onWatchNextTap = (contentId) { - appLogger.d('Watch Next tap: $contentId'); - _handleWatchNextContentId(contentId); + systemShelf.onShelfItemTap = (contentId) { + appLogger.d('System shelf tap: $contentId'); + _handleShelfContentId(contentId); }; // Check for pending deep link from cold start WidgetsBinding.instance.addPostFrameCallback((_) async { - final contentId = await watchNext.getInitialDeepLink(); + final contentId = await systemShelf.getInitialDeepLink(); if (contentId != null && mounted) { - appLogger.d('Watch Next initial deep link: $contentId'); - unawaited(_handleWatchNextContentId(contentId)); + appLogger.d('System shelf initial deep link: $contentId'); + unawaited(_handleShelfContentId(contentId)); } }); } - /// Handle a Watch Next content ID by fetching metadata and starting playback - Future _handleWatchNextContentId(String contentId) async { + /// Handle a launcher shelf content ID by fetching metadata and starting playback. + Future _handleShelfContentId(String contentId) async { if (!mounted) return; - final parsed = WatchNextService.parseContentId(contentId); + final parsed = SystemShelfService.parseContentId(contentId); if (parsed == null) { - appLogger.w('Watch Next: invalid content ID: $contentId'); + appLogger.w('System shelf: invalid content ID: $contentId'); return; } @@ -685,7 +685,7 @@ class _MainScreenState extends State final client = multiServer.getClientForServer(serverId); if (client == null) { - appLogger.w('Watch Next: server $serverId not available'); + appLogger.w('System shelf: server $serverId not available'); return; } @@ -695,7 +695,7 @@ class _MainScreenState extends State unawaited(navigateToVideoPlayer(context, metadata: metadata)); } catch (e) { - appLogger.e('Watch Next: failed to navigate to media', error: e); + appLogger.e('System shelf: failed to navigate to media', error: e); } } diff --git a/lib/services/watch_next_service.dart b/lib/services/system_shelf_service.dart similarity index 55% rename from lib/services/watch_next_service.dart rename to lib/services/system_shelf_service.dart index 868520c5..5003a170 100644 --- a/lib/services/watch_next_service.dart +++ b/lib/services/system_shelf_service.dart @@ -1,101 +1,119 @@ import 'dart:io' show Platform; -import '../media/ids.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import '../media/ids.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; +import '../utils/platform_detector.dart'; import 'settings_service.dart' show EpisodePosterMode; -/// Service for syncing On Deck / Continue Watching content to Android TV's Watch Next row. -class WatchNextService { - static const MethodChannel _channel = MethodChannel('com.plezy/watch_next'); +/// Syncs Continue Watching content to platform launcher surfaces. +/// +/// Android uses the Watch Next row. tvOS uses the app's Top Shelf extension. +class SystemShelfService { + static const MethodChannel _androidChannel = MethodChannel('com.plezy/watch_next'); + static const MethodChannel _tvosChannel = MethodChannel('com.plezy/system_shelf'); - static final WatchNextService _instance = WatchNextService._internal(); - factory WatchNextService() => _instance; + static final SystemShelfService _instance = SystemShelfService._internal(); + factory SystemShelfService() => _instance; - WatchNextService._internal() { - _channel.setMethodCallHandler(_handleMethodCall); + SystemShelfService._internal() { + _androidChannel.setMethodCallHandler(_handleMethodCall); + _tvosChannel.setMethodCallHandler(_handleMethodCall); } - /// Callback for when a Watch Next item is tapped (warm start deep link). - ValueChanged? onWatchNextTap; + /// Callback for warm-start launcher surface taps. + ValueChanged? onShelfItemTap; + + MethodChannel? get _channel { + if (Platform.isAndroid) return _androidChannel; + if (Platform.isIOS && PlatformDetector.isAppleTV()) return _tvosChannel; + return null; + } Future _handleMethodCall(MethodCall call) async { - if (call.method == 'onWatchNextTap') { - final contentId = call.arguments['contentId'] as String?; + if (call.method == 'onWatchNextTap' || call.method == 'onShelfItemTap') { + final args = call.arguments; + final contentId = args is Map ? args['contentId'] as String? : null; if (contentId != null) { - onWatchNextTap?.call(contentId); + onShelfItemTap?.call(contentId); } } } /// Get a pending deep link from cold start (consumed on first call). Future getInitialDeepLink() async { - if (!Platform.isAndroid) return null; + final channel = _channel; + if (channel == null) return null; try { - return await _channel.invokeMethod('getInitialDeepLink'); + return await channel.invokeMethod('getInitialDeepLink'); } catch (e) { - appLogger.w('Failed to get initial deep link', error: e); + appLogger.w('Failed to get system shelf initial deep link', error: e); return null; } } - /// Check if Watch Next is supported (Android TV only). + /// Check whether the current platform has a launcher shelf integration. Future isSupported() async { - if (!Platform.isAndroid) return false; + final channel = _channel; + if (channel == null) return false; try { - return await _channel.invokeMethod('isSupported') ?? false; - } catch (e) { + return await channel.invokeMethod('isSupported') ?? false; + } catch (_) { return false; } } - /// Sync On Deck items to Watch Next row. - Future syncFromOnDeck( - List onDeckItems, + /// Sync Continue Watching items to the current platform's launcher shelf. + Future syncFromContinueWatching( + List continueWatchingItems, MediaServerClient Function(ServerId serverId) getClientForServerId, { bool hideSpoilers = false, }) async { - if (!Platform.isAndroid) return false; + final channel = _channel; + if (channel == null) return false; try { final supported = await isSupported(); if (!supported) return false; - final items = onDeckItems.map((item) { - return _convertToWatchNextItem(item, getClientForServerId, hideSpoilers: hideSpoilers); + final items = continueWatchingItems.map((item) { + return _convertToShelfItem(item, getClientForServerId, hideSpoilers: hideSpoilers); }).toList(); - return await _channel.invokeMethod('sync', {'items': items}) ?? false; + return await channel.invokeMethod('sync', {'items': items}) ?? false; } catch (e) { - appLogger.e('Failed to sync Watch Next', error: e); + appLogger.e('Failed to sync system shelf', error: e); return false; } } - /// Clear all Watch Next entries. + /// Clear all launcher shelf entries owned by the app. Future clear() async { - if (!Platform.isAndroid) return false; + final channel = _channel; + if (channel == null) return false; try { - return await _channel.invokeMethod('clear') ?? false; + return await channel.invokeMethod('clear') ?? false; } catch (e) { - appLogger.e('Failed to clear Watch Next', error: e); + appLogger.e('Failed to clear system shelf', error: e); return false; } } - /// Remove a single item from Watch Next. + /// Remove a single launcher shelf item. Future removeItem(ServerId serverId, String ratingKey) async { - if (!Platform.isAndroid) return false; + final channel = _channel; + if (channel == null) return false; try { final contentId = _buildContentId(serverId, ratingKey); - return await _channel.invokeMethod('remove', {'contentId': contentId}) ?? false; + return await channel.invokeMethod('remove', {'contentId': contentId}) ?? false; } catch (e) { - appLogger.e('Failed to remove Watch Next item', error: e); + appLogger.e('Failed to remove system shelf item', error: e); return false; } } @@ -113,7 +131,7 @@ class WatchNextService { return (ServerId(parts.first), parts.sublist(1).join('_')); } - Map _convertToWatchNextItem( + Map _convertToShelfItem( MediaItem item, MediaServerClient Function(ServerId serverId) getClientForServerId, { bool hideSpoilers = false, @@ -134,7 +152,7 @@ class WatchNextService { } } } catch (e) { - appLogger.w('Failed to get poster URL for Watch Next: ${item.title}', error: e); + appLogger.w('Failed to get shelf poster URL for ${item.title}', error: e); } final String title; diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj index e37100c8..006b4edc 100644 --- a/tvos/Runner.xcodeproj/project.pbxproj +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 055E465F9095D0B6E9B91D46 /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */; }; + 1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */; }; + 28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F829B3F190657106F66379 /* TopShelfProvider.swift */; }; 35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; @@ -22,12 +24,24 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; }; AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; }; + BDD87DA7F5C435F9DBB8F9BE /* TopShelfExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C6A15611158B29B0FF43A960 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 824D2C68D1F1206932E118C2 /* Pods_Runner.framework */; }; CD1C0534948840272E58248E /* PathMonitorConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AEF79DFBD1D80F00AB39CDA /* PathMonitorConnectivityProvider.swift */; }; + D2A548D9DE1A0F319B30B74C /* SystemShelfPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */; }; E3DEAD2AAFC347A2E55AC0F7 /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */; }; E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 3E4E59C5E29F44BA64BEB559 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 63C63C245D7F38BFF091CEC3; + remoteInfo = TopShelfExtension; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -39,11 +53,25 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + AE0235548DB8213618795857 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BDD87DA7F5C435F9DBB8F9BE /* TopShelfExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; name = TopShelfExtension.appex; path = TopShelfExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPlugin.swift; sourceTree = ""; }; 0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; }; 34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = ""; }; @@ -56,6 +84,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 824D2C68D1F1206932E118C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TopShelfExtension.entitlements; sourceTree = ""; }; + 937C0D45D6114EF1E957F5F6 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -66,13 +96,23 @@ 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = ""; }; A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = ""; }; A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = ""; }; + BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = ""; }; D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = ""; }; D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = ""; }; + F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = ""; }; F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 43E2EE359822E384AD7E8A2E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -114,6 +154,17 @@ path = connectivity_plus; sourceTree = ""; }; + 47C2D3F50A23DE33DE92F592 /* TopShelfExtension */ = { + isa = PBXGroup; + children = ( + F2F829B3F190657106F66379 /* TopShelfProvider.swift */, + BBCB49C8AE9E90DEF97A87CA /* Info.plist */, + 9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */, + ); + name = TopShelfExtension; + path = TopShelfExtension; + sourceTree = ""; + }; 53CDD29681161166962B9FA5 /* Pods */ = { isa = PBXGroup; children = ( @@ -128,6 +179,7 @@ isa = PBXGroup; children = ( 824D2C68D1F1206932E118C2 /* Pods_Runner.framework */, + 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */, ); name = Frameworks; sourceTree = ""; @@ -160,6 +212,7 @@ 97C146EF1CF9000F007C117D /* Products */, 53CDD29681161166962B9FA5 /* Pods */, 5833EC5B503BBB4E370BA1B7 /* Frameworks */, + 47C2D3F50A23DE33DE92F592 /* TopShelfExtension */, ); sourceTree = ""; }; @@ -167,6 +220,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */, ); name = Products; sourceTree = ""; @@ -182,6 +236,8 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, BF0B7DFE378943E9D7865D18 /* MpvPlayer */, D58B788B11920D95BC7D750C /* Plugins */, + 0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */, + 937C0D45D6114EF1E957F5F6 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; @@ -224,6 +280,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 63C63C245D7F38BFF091CEC3 /* TopShelfExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = C995F7A5B711B6DB7E97B000 /* Build configuration list for PBXNativeTarget "TopShelfExtension" */; + buildPhases = ( + B1C48839AB6C24EF099DDFA3 /* Sources */, + 43E2EE359822E384AD7E8A2E /* Frameworks */, + 64CEA7DE6B9394CE88783302 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TopShelfExtension; + productName = TopShelfExtension; + productReference = 035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -234,12 +307,14 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + AE0235548DB8213618795857 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 7C33FC680D67B3859BF9AE6C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( + F0399FC35D3A6ED67E74810D /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -281,11 +356,19 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 63C63C245D7F38BFF091CEC3 /* TopShelfExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 64CEA7DE6B9394CE88783302 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -388,11 +471,29 @@ 81F08E404EBEB19B00FF148D /* ConnectivityPlusPlugin.swift in Sources */, A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */, CD1C0534948840272E58248E /* PathMonitorConnectivityProvider.swift in Sources */, + D2A548D9DE1A0F319B30B74C /* SystemShelfPlugin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B1C48839AB6C24EF099DDFA3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F0399FC35D3A6ED67E74810D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = TopShelfExtension; + target = 63C63C245D7F38BFF091CEC3 /* TopShelfExtension */; + targetProxy = 3E4E59C5E29F44BA64BEB559 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -470,6 +571,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -500,6 +602,98 @@ }; name = Profile; }; + 37D721B4A1E3E0EDC16D1456 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = TopShelfExtension/TopShelfExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = TopShelfExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.TopShelfExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 14.0; + }; + name = Debug; + }; + 4A02CC70113C79647D6EEB12 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = TopShelfExtension/TopShelfExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = TopShelfExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.TopShelfExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 14.0; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 65F6020FE0DF48006BADE49C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = TopShelfExtension/TopShelfExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = TopShelfExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.TopShelfExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 14.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -614,6 +808,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -651,6 +846,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -704,6 +900,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C995F7A5B711B6DB7E97B000 /* Build configuration list for PBXNativeTarget "TopShelfExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 65F6020FE0DF48006BADE49C /* Release */, + 37D721B4A1E3E0EDC16D1456 /* Debug */, + 4A02CC70113C79647D6EEB12 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/tvos/Runner/AppDelegate.swift b/tvos/Runner/AppDelegate.swift index 4d5d3696..dd6bf8b6 100644 --- a/tvos/Runner/AppDelegate.swift +++ b/tvos/Runner/AppDelegate.swift @@ -128,6 +128,10 @@ import wakelock_plus application.beginReceivingRemoteControlEvents() + if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL { + _ = SystemShelfPlugin.handleOpenURL(url) + } + if let r = self.registrar(forPlugin: "SharedPreferencesPlugin") { SharedPreferencesPlugin.register(with: r) } @@ -155,7 +159,21 @@ import wakelock_plus if let r = self.registrar(forPlugin: "WakelockPlusPlugin") { WakelockPlusPlugin.register(with: r) } + if let r = self.registrar(forPlugin: "SystemShelfPlugin") { + SystemShelfPlugin.register(with: r) + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func application( + _ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + if SystemShelfPlugin.handleOpenURL(url) { + return true + } + return super.application(application, open: url, options: options) + } } diff --git a/tvos/Runner/Info.plist b/tvos/Runner/Info.plist index 00d1624e..65f2a95f 100644 --- a/tvos/Runner/Info.plist +++ b/tvos/Runner/Info.plist @@ -22,6 +22,17 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleURLName + com.edde746.plezy.play + CFBundleURLSchemes + + plezy + + + GCSupportedGameControllers @@ -39,6 +50,11 @@ ITSAppUsesNonExemptEncryption + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/tvos/Runner/Runner.entitlements b/tvos/Runner/Runner.entitlements new file mode 100644 index 00000000..c7b1f173 --- /dev/null +++ b/tvos/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.edde746.plezy + + + diff --git a/tvos/Runner/SystemShelfPlugin.swift b/tvos/Runner/SystemShelfPlugin.swift new file mode 100644 index 00000000..20128389 --- /dev/null +++ b/tvos/Runner/SystemShelfPlugin.swift @@ -0,0 +1,177 @@ +import Foundation +import TVServices + +#if os(tvOS) + import Flutter + + final class SystemShelfPlugin: NSObject, FlutterPlugin { + private static let channelName = "com.plezy/system_shelf" + private static let appGroupIdentifier = "group.com.edde746.plezy" + private static let cacheFileName = "PlezySystemShelfCache.json" + private static var pendingDeepLink: String? + private static var methodChannel: FlutterMethodChannel? + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: registrar.messenger() + ) + methodChannel = channel + registrar.addMethodCallDelegate(SystemShelfPlugin(), channel: channel) + } + + static func handleOpenURL(_ url: URL) -> Bool { + guard let contentId = contentId(from: url) else { return false } + pendingDeepLink = contentId + methodChannel?.invokeMethod("onShelfItemTap", arguments: ["contentId": contentId]) + return true + } + + private static func contentId(from url: URL) -> String? { + guard url.scheme == "plezy", url.host == "play" else { return nil } + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + return components?.queryItems?.first { $0.name == "content_id" }?.value + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + let supported = Self.cacheURL != nil + if !supported { + Self.log("App Group container unavailable") + } + result(supported) + case "sync": + guard let args = call.arguments as? [String: Any], let rawItems = args["items"] as? [[String: Any]] else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing items", details: nil)) + return + } + result(Self.writeItems(rawItems.map(Self.normalizedItem))) + case "clear": + result(Self.clearCache()) + case "remove": + guard let args = call.arguments as? [String: Any], let contentId = args["contentId"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing contentId", details: nil)) + return + } + result(Self.removeItem(contentId: contentId)) + case "getInitialDeepLink": + let contentId = Self.pendingDeepLink + Self.pendingDeepLink = nil + result(contentId) + default: + result(FlutterMethodNotImplemented) + } + } + + private static var appGroupContainerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } + + private static var cacheURL: URL? { + appGroupContainerURL?.appendingPathComponent(cacheFileName, isDirectory: false) + } + + private static func log(_ message: String) { + NSLog("PlezySystemShelf: %@", message) + } + + private static func normalizedItem(_ item: [String: Any]) -> [String: Any] { + item.reduce(into: [String: Any]()) { result, entry in + if entry.value is NSNull { return } + result[entry.key] = entry.value + } + } + + private static func writeItems(_ items: [[String: Any]]) -> Bool { + let payload: [String: Any] = [ + "updatedAt": Date().timeIntervalSince1970, + "sections": [ + [ + "id": "continue_watching", + "title": "Continue Watching", + "items": items, + ] + ], + ] + + return writePayload(payload) + } + + private static func writePayload(_ payload: [String: Any]) -> Bool { + guard let url = cacheURL else { + log("Cannot write cache because App Group container is unavailable") + return false + } + + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload) + else { + log("Cannot write cache because payload is not valid JSON") + return false + } + + do { + try data.write(to: url, options: [.atomic]) + } catch { + log("Failed to write cache: \(error)") + return false + } + + let itemCount = + (payload["sections"] as? [[String: Any]])?.reduce(0) { count, section in + count + ((section["items"] as? [[String: Any]])?.count ?? 0) + } ?? 0 + log("Wrote cache with \(itemCount) items") + TVTopShelfContentProvider.topShelfContentDidChange() + return true + } + + private static func clearCache() -> Bool { + guard let url = cacheURL else { + log("Cannot clear cache because App Group container is unavailable") + return false + } + + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } catch { + log("Failed to clear cache: \(error)") + return false + } + + log("Cleared cache") + TVTopShelfContentProvider.topShelfContentDidChange() + return true + } + + private static func removeItem(contentId: String) -> Bool { + guard let url = cacheURL, + let data = try? Data(contentsOf: url), + var payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sections = payload["sections"] as? [[String: Any]] + else { + log("Cannot remove cache item because cache is unavailable or invalid") + return false + } + + var removed = false + let filteredSections = sections.map { section -> [String: Any] in + var nextSection = section + if let items = section["items"] as? [[String: Any]] { + let filteredItems = items.filter { $0["contentId"] as? String != contentId } + removed = removed || filteredItems.count != items.count + nextSection["items"] = filteredItems + } + return nextSection + } + + if !removed { return false } + payload["updatedAt"] = Date().timeIntervalSince1970 + payload["sections"] = filteredSections + return writePayload(payload) + } + } +#endif diff --git a/tvos/TopShelfExtension/Info.plist b/tvos/TopShelfExtension/Info.plist new file mode 100644 index 00000000..4733b610 --- /dev/null +++ b/tvos/TopShelfExtension/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Plezy Top Shelf + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSExtension + + NSExtensionPointIdentifier + com.apple.tv-top-shelf + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).TopShelfProvider + + UIRequiredDeviceCapabilities + + arm64 + + + diff --git a/tvos/TopShelfExtension/TopShelfExtension.entitlements b/tvos/TopShelfExtension/TopShelfExtension.entitlements new file mode 100644 index 00000000..c7b1f173 --- /dev/null +++ b/tvos/TopShelfExtension/TopShelfExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.edde746.plezy + + + diff --git a/tvos/TopShelfExtension/TopShelfProvider.swift b/tvos/TopShelfExtension/TopShelfProvider.swift new file mode 100644 index 00000000..3d7ab5b7 --- /dev/null +++ b/tvos/TopShelfExtension/TopShelfProvider.swift @@ -0,0 +1,141 @@ +import Foundation +import TVServices + +private enum TopShelfShared { + static let appGroupIdentifier = "group.com.edde746.plezy" + static let cacheFileName = "PlezySystemShelfCache.json" + + static var cacheURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? + .appendingPathComponent(cacheFileName, isDirectory: false) + } + + static func log(_ message: String) { + NSLog("PlezyTopShelf: %@", message) + } +} + +private struct TopShelfCachePayload: Decodable { + struct Section: Decodable { + let id: String + let title: String + let items: [Item] + } + + struct Item: Decodable { + let contentId: String + let title: String + let episodeTitle: String? + let description: String? + let posterUri: String? + let type: String? + let duration: Double? + let lastPlaybackPosition: Double? + let seasonNumber: Int? + let episodeNumber: Int? + } + + let sections: [Section] +} + +final class TopShelfProvider: TVTopShelfContentProvider { + override func loadTopShelfContent() async -> (any TVTopShelfContent)? { + buildContent() + } + + private func buildContent() -> TVTopShelfContent? { + guard let url = TopShelfShared.cacheURL else { + TopShelfShared.log("App Group container unavailable") + return nil + } + + guard FileManager.default.fileExists(atPath: url.path) else { + TopShelfShared.log("Cache file missing") + return nil + } + + let payload: TopShelfCachePayload + do { + let data = try Data(contentsOf: url) + payload = try JSONDecoder().decode(TopShelfCachePayload.self, from: data) + } catch { + TopShelfShared.log("Failed to read cache: \(error)") + return nil + } + + let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection? in + let items = section.items.compactMap(makeTopShelfItem) + guard !items.isEmpty else { return nil } + + let collection = TVTopShelfItemCollection(items: items) + collection.title = section.title + return collection + } + + guard !sections.isEmpty else { + TopShelfShared.log("Cache has no displayable items") + return nil + } + + let itemCount = sections.reduce(0) { $0 + $1.items.count } + TopShelfShared.log("Loaded \(itemCount) items") + return TVTopShelfSectionedContent(sections: sections) + } + + private func makeTopShelfItem(_ cacheItem: TopShelfCachePayload.Item) -> TVTopShelfSectionedItem? { + guard !cacheItem.contentId.isEmpty else { return nil } + + let item = TVTopShelfSectionedItem(identifier: cacheItem.contentId) + item.title = displayTitle(for: cacheItem) + item.imageShape = .hdtv + + if let duration = cacheItem.duration, duration > 0, + let position = cacheItem.lastPlaybackPosition, position > 0 + { + item.playbackProgress = min(max(position / duration, 0), 1) + } + + if let url = deepLinkURL(contentId: cacheItem.contentId) { + let action = TVTopShelfAction(url: url) + item.displayAction = action + item.playAction = action + } + + if let posterUri = cacheItem.posterUri, let imageURL = URL(string: posterUri) { + item.setImageURL(imageURL, for: .screenScale1x) + item.setImageURL(imageURL, for: .screenScale2x) + } + + return item + } + + private func displayTitle(for item: TopShelfCachePayload.Item) -> String { + guard let episodeTitle = item.episodeTitle, !episodeTitle.isEmpty else { + return item.title + } + + let episodePrefix: String? = { + if let seasonNumber = item.seasonNumber, let episodeNumber = item.episodeNumber { + return "S\(seasonNumber) E\(episodeNumber)" + } + if let episodeNumber = item.episodeNumber { + return "E\(episodeNumber)" + } + return nil + }() + + if let episodePrefix { + return "\(item.title) - \(episodePrefix) - \(episodeTitle)" + } + return "\(item.title) - \(episodeTitle)" + } + + private func deepLinkURL(contentId: String) -> URL? { + var components = URLComponents() + components.scheme = "plezy" + components.host = "play" + components.queryItems = [URLQueryItem(name: "content_id", value: contentId)] + return components.url + } +} diff --git a/tvos/scripts/wire_top_shelf.rb b/tvos/scripts/wire_top_shelf.rb new file mode 100644 index 00000000..46364352 --- /dev/null +++ b/tvos/scripts/wire_top_shelf.rb @@ -0,0 +1,145 @@ +#!/usr/bin/env ruby +# Adds Plezy's tvOS Top Shelf extension target and Runner-side bridge source. + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Runner.xcodeproj', __dir__) +project = Xcodeproj::Project.open(PROJECT_PATH) +runner = project.targets.find { |t| t.name == 'Runner' } +raise 'Runner target not found' unless runner + +main_group = project.main_group +runner_group = main_group['Runner'] +raise 'Runner group not found' unless runner_group +products_group = main_group['Products'] || main_group.new_group('Products') +frameworks_group = main_group['Frameworks'] || main_group.new_group('Frameworks') +generated_config_ref = project.files.find { |file| file.path == 'Flutter/Generated.xcconfig' } +raise 'Generated.xcconfig not found' unless generated_config_ref + +def ensure_file(group, path, name: nil, source_tree: '') + existing = group.files.find { |f| f.path == path || f.display_name == (name || File.basename(path)) } + return existing if existing + + ref = group.new_file(path) + ref.name = name if name + ref.source_tree = source_tree + ref +end + +def ensure_source(target, file_ref) + phase = target.source_build_phase + return if phase.files_references.include?(file_ref) + + phase.add_file_reference(file_ref, true) +end + +def ensure_copy_file(project, phase, file_ref) + existing = phase.files.find { |build_file| build_file.file_ref == file_ref } + return existing if existing + + build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile) + build_file.file_ref = file_ref + build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } + phase.files << build_file + build_file +end + +def ensure_framework(target, file_ref) + phase = target.frameworks_build_phase + return if phase.files_references.include?(file_ref) + + phase.add_file_reference(file_ref, true) +end + +system_shelf_ref = ensure_file(runner_group, 'SystemShelfPlugin.swift') +ensure_source(runner, system_shelf_ref) +ensure_file(runner_group, 'Runner.entitlements') + +extension_group = main_group['TopShelfExtension'] || main_group.new_group('TopShelfExtension', 'TopShelfExtension') +top_shelf_ref = ensure_file(extension_group, 'TopShelfProvider.swift') +ensure_file(extension_group, 'Info.plist') +ensure_file(extension_group, 'TopShelfExtension.entitlements') + +extension_target = project.targets.find { |t| t.name == 'TopShelfExtension' } +unless extension_target + extension_target = project.new_target(:app_extension, 'TopShelfExtension', :tvos, '14.0') +end +extension_target.product_type = 'com.apple.product-type.app-extension' + +ensure_source(extension_target, top_shelf_ref) + +removed_framework_refs = [] +extension_target.frameworks_build_phase.files.delete_if do |build_file| + next false unless build_file.file_ref&.display_name == 'Foundation.framework' + + removed_framework_refs << build_file.file_ref + true +end +removed_framework_refs.compact.uniq.each do |file_ref| + still_used = project.targets.any? do |target| + target.frameworks_build_phase.files_references.include?(file_ref) + end + file_ref.remove_from_project unless still_used +end + +tv_services_ref = ensure_file( + frameworks_group, + 'System/Library/Frameworks/TVServices.framework', + name: 'TVServices.framework', + source_tree: 'SDKROOT' +) +ensure_framework(extension_target, tv_services_ref) + +extension_product = extension_target.product_reference +extension_product.name = 'TopShelfExtension.appex' +extension_product.path = 'TopShelfExtension.appex' +extension_product.explicit_file_type = 'wrapper.app-extension' +products_group.children << extension_product unless products_group.children.include?(extension_product) + +runner.add_dependency(extension_target) unless runner.dependencies.any? { |d| d.target == extension_target } + +embed_phase = runner.copy_files_build_phases.find { |phase| phase.name == 'Embed App Extensions' } +unless embed_phase + embed_phase = project.new(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) + embed_phase.name = 'Embed App Extensions' + embed_phase.dst_subfolder_spec = '13' + embed_phase.dst_path = '' + runner.build_phases.insert(-3, embed_phase) +end +ensure_copy_file(project, embed_phase, extension_product) + +runner.build_configurations.each do |config| + config.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'Runner/Runner.entitlements' +end + +extension_target.build_configurations.each do |config| + config.base_configuration_reference = generated_config_ref + + settings = config.build_settings + settings['APPLICATION_EXTENSION_API_ONLY'] = 'YES' + settings['CLANG_ENABLE_MODULES'] = 'YES' + settings['CODE_SIGN_ENTITLEMENTS'] = 'TopShelfExtension/TopShelfExtension.entitlements' + settings['CODE_SIGN_IDENTITY'] = 'Apple Development' + settings['CODE_SIGN_STYLE'] = 'Automatic' + settings['CURRENT_PROJECT_VERSION'] = '$(FLUTTER_BUILD_NUMBER)' + settings['DEVELOPMENT_TEAM'] = 'G88U5B5783' + settings['ENABLE_BITCODE'] = 'NO' + settings['INFOPLIST_FILE'] = 'TopShelfExtension/Info.plist' + settings['LD_RUNPATH_SEARCH_PATHS'] = [ + '$(inherited)', + '@executable_path/Frameworks', + '@executable_path/../../Frameworks', + ] + settings['MARKETING_VERSION'] = '$(FLUTTER_BUILD_NAME)' + settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.edde746.plezy.TopShelfExtension' + settings['PRODUCT_NAME'] = '$(TARGET_NAME)' + settings['SDKROOT'] = 'appletvos' + settings['SKIP_INSTALL'] = 'YES' + settings['SUPPORTED_PLATFORMS'] = 'appletvos appletvsimulator' + settings['SWIFT_VERSION'] = '5.0' + settings['TARGETED_DEVICE_FAMILY'] = '3' + settings['TVOS_DEPLOYMENT_TARGET'] = '14.0' +end + +project.save +puts 'Saved Top Shelf wiring' diff --git a/tvos/scripts/xcode_appletv.sh b/tvos/scripts/xcode_appletv.sh index ae34926f..eb76765c 100755 --- a/tvos/scripts/xcode_appletv.sh +++ b/tvos/scripts/xcode_appletv.sh @@ -21,6 +21,12 @@ else fi ReadPubspecVersion() { + if [[ -n "${FLUTTER_BUILD_NAME:-}" && -n "${FLUTTER_BUILD_NUMBER:-}" ]]; then + export FLUTTER_BUILD_NAME + export FLUTTER_BUILD_NUMBER + return 0 + fi + local app_path="${FLUTTER_APPLICATION_PATH:-}" if [[ -z "$app_path" ]]; then if [[ -n "${PROJECT_DIR:-}" ]]; then @@ -98,6 +104,13 @@ SyncRunnerVersion() { echo " └─Syncing Runner version $FLUTTER_BUILD_NAME ($FLUTTER_BUILD_NUMBER)" SetPlistString "$plist" CFBundleShortVersionString "$FLUTTER_BUILD_NAME" SetPlistString "$plist" CFBundleVersion "$FLUTTER_BUILD_NUMBER" + + local top_shelf_plist="$TARGET_BUILD_DIR/$WRAPPER_NAME/PlugIns/TopShelfExtension.appex/Info.plist" + if [[ -f "$top_shelf_plist" ]]; then + echo " └─Syncing TopShelfExtension version $FLUTTER_BUILD_NAME ($FLUTTER_BUILD_NUMBER)" + SetPlistString "$top_shelf_plist" CFBundleShortVersionString "$FLUTTER_BUILD_NAME" + SetPlistString "$top_shelf_plist" CFBundleVersion "$FLUTTER_BUILD_NUMBER" + fi } EngineOutputExists() {