diff --git a/.gitignore b/.gitignore index 7feb3aab..d445f603 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,20 @@ migrate_working_dir/ /build/ /debug-info/ +# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh) +tvos/build/ +tvos/Pods/ +tvos/Podfile.lock +tvos/Runner.xcworkspace/xcuserdata/ +tvos/Runner.xcodeproj/xcuserdata/ +tvos/Runner.xcodeproj/project.xcworkspace/xcuserdata/ +tvos/Flutter/Generated.xcconfig +tvos/Flutter/flutter_export_environment.sh +tvos/Flutter/libPods-Runner.a +tvos/tvos_flutter_assets/ +tvos/Runner/GeneratedPluginRegistrant.h +tvos/Runner/GeneratedPluginRegistrant.m + # Symbolication related app.*.symbols diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index a6a5fec2..e6b6b9c4 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -1,6 +1,47 @@ import AVKit import UIKit +#if os(tvOS) +// tvOS stub: AVPictureInPictureController has different constraints on tvOS +// and is not supported by the Plezy flow. Provide a no-op shell so callers +// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is +// never attempted at runtime. +protocol MpvPipDelegate: AnyObject { + func pipWillStart() + func pipDidStart() + func pipDidStop(restored: Bool) + func pipDidFailToStart(error: Error?) + func pipSetPlaying(_ playing: Bool) + func pipSkip(byInterval seconds: Double) + var isPipPlaying: Bool { get } + var pipDuration: Double { get } +} + +class MpvPipController: NSObject { + static var isSupported: Bool { false } + weak var delegate: MpvPipDelegate? + var isPipActive: Bool { false } + var autoStartEnabled: Bool { false } + var layerPointer: UnsafeMutableRawPointer { + // Return a dummy non-null pointer — layerPointer is handed to mpv for + // rendering into PiP, which never activates on tvOS. + UnsafeMutableRawPointer(bitPattern: 0x1)! + } + func setup(with layer: CALayer, containerView: UIView) {} + func setAutoStart(_ enabled: Bool) {} + func warmLayer(currentTime: Double, isPlaying: Bool) {} + func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {} + func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { + completion(false) + } + func stopPip() {} + func invalidatePlaybackState() {} + func flushLayer() {} + func syncTimebase(currentTime: Double, isPlaying: Bool) {} + func teardown() {} +} +#else + /// Delegate to notify the plugin of PiP lifecycle events protocol MpvPipDelegate: AnyObject { /// Called when PiP is about to start (system or app-initiated) @@ -367,3 +408,5 @@ private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate, completionHandler() } } + +#endif // !os(tvOS) diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 12f10252..e1f84281 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -127,11 +127,13 @@ class MpvPlayerCore: MpvPlayerCoreBase { guard let metalLayer else { return } var edrHeadroom: CGFloat = 1.0 + #if os(iOS) if #available(iOS 16.0, *) { edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 metalLayer.wantsExtendedDynamicRangeContent = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 } + #endif let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 print( diff --git a/lib/main.dart b/lib/main.dart index bb287144..7d30b643 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'dart:ui' show AppExitResponse; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'dart:io' show Platform, ProcessInfo; @@ -86,10 +88,26 @@ void _absorbZeroOffsetPointerEvent(PointerEvent event) { } } +/// Register platform plugin stores manually for tvOS. Flutter's tool +/// doesn't support tvOS so it never generates a plugin registrant for it. +/// Each plugin whose iOS Swift implementation is tvOS-compatible must be +/// wired here; the Swift side (GeneratedPluginRegistrant.m / AppDelegate) +/// also needs to call the plugin's Swift register(with:) to attach its +/// message channels. +void _registerTvosPlatformPlugins() { + if (!Platform.isIOS) return; // tvOS reports as iOS via dart:io. + SharedPreferencesFoundation.registerWith(); +} + Future main() async { WidgetsFlutterBinding.ensureInitialized(); _installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug + // On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS + // target in Flutter's tool), so register platform stores manually for + // the plugins we use. + _registerTvosPlatformPlugins(); + if (_enableSentry) { final packageInfo = await PackageInfo.fromPlatform(); @@ -143,10 +161,12 @@ Future _bootstrapApp() async { futures.add(windowManager.ensureInitialized()); } - // Initialize TV detection and PiP service for Android - if (Platform.isAndroid) { + // Initialize TV detection (Android leanback or Apple TV) and PiP on Android. + if (Platform.isAndroid || Platform.isIOS) { futures.add(TvDetectionService.getInstance(forceTv: settings.getForceTvMode())); - // Initialize PiP service to listen for PiP state changes + } + if (Platform.isAndroid) { + // Initialize PiP service to listen for PiP state changes (Android only). PipService(); } @@ -702,9 +722,23 @@ class _MainAppState extends State with WidgetsBindingObserver { navigatorKey: rootNavigatorKey, navigatorObservers: [routeObserver, BackKeySuppressorObserver()], home: const OrientationAwareSetup(), + // Siri Remote select + gamepad A report as + // LogicalKeyboardKey.{select,gameButtonA} which aren't + // in Flutter's default shortcut set — Material-level + // widgets (PopupMenuItem, showModalBottomSheet actions) + // ignore them. Map both to ActivateIntent so tapping + // select on tvOS activates the focused widget. + shortcuts: { + ...WidgetsApp.defaultShortcuts, + const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(), + const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(), + }, builder: (context, child) => ScaffoldMessenger( key: rootScaffoldMessengerKey, - child: Scaffold(backgroundColor: Colors.transparent, body: child), + child: Scaffold( + backgroundColor: Colors.transparent, + body: _AppleTvScale(child: child), + ), ), ), ), @@ -719,6 +753,55 @@ class _MainAppState extends State with WidgetsBindingObserver { } } +/// On Apple TV the system hands Flutter a 1920×1080 surface at +/// devicePixelRatio 1.0, the same logical pixel count as a phablet. That's +/// too dense for a 10ft viewing distance, so everything ends up tiny. We +/// shrink the effective logical size to half and scale the rendered output +/// back up so fonts, icons, and paddings end up visually ~2× larger — roughly +/// matching the UI feel of Android TV (which renders at lower logical DPI). +class _AppleTvScale extends StatelessWidget { + final Widget? child; + const _AppleTvScale({required this.child}); + + static const double _scale = 2.0; + + @override + Widget build(BuildContext context) { + if (child == null || !PlatformDetector.isAppleTV()) { + return child ?? const SizedBox.shrink(); + } + return LayoutBuilder( + builder: (context, constraints) { + final logicalSize = Size(constraints.maxWidth / _scale, constraints.maxHeight / _scale); + final outerQ = MediaQuery.of(context); + // tvOS reports conservative overscan insets (~60pt top/bottom, + // ~90pt left/right). Modern TVs don't overscan, so treat them as + // dead margin and zero them out — the UI can use the full surface. + return Transform.scale( + scale: _scale, + alignment: Alignment.topLeft, + transformHitTests: true, + child: SizedBox( + width: logicalSize.width, + height: logicalSize.height, + child: MediaQuery( + data: outerQ.copyWith( + size: logicalSize, + devicePixelRatio: outerQ.devicePixelRatio * _scale, + padding: EdgeInsets.zero, + viewPadding: EdgeInsets.zero, + viewInsets: EdgeInsets.zero, + systemGestureInsets: EdgeInsets.zero, + ), + child: child!, + ), + ), + ); + }, + ); + } +} + /// Hydrates Trakt and MAL/AniList/Simkl providers with the active Plex /// profile's sessions and rebinds their services whenever the user switches. /// diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index b1ce9adf..d16e5f7b 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; +import '../utils/platform_detector.dart'; /// Navigation tab identifiers enum NavigationTabId { discover, libraries, liveTv, search, downloads, settings } @@ -31,6 +32,7 @@ class NavigationTab { return allNavigationTabs.where((tab) { if (isOffline && tab.onlineOnly) return false; if (tab.id == NavigationTabId.liveTv && !hasLiveTv) return false; + if (tab.id == NavigationTabId.downloads && PlatformDetector.isAppleTV()) return false; return true; }).toList(); } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 845426e3..ca3cb41a 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -9,6 +9,7 @@ import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/download_utils.dart'; +import '../utils/platform_detector.dart'; import '../utils/plex_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; @@ -111,13 +112,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen return blurArtwork( CachedNetworkImage( imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, fit: BoxFit.cover, placeholder: (context, url) => Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), @@ -1455,6 +1458,7 @@ class _DiscoverScreenState extends State return blurArtwork( CachedNetworkImage( imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, filterQuality: FilterQuality.medium, fit: BoxFit.contain, memCacheWidth: (400 * dpr).clamp(200, 800).round(), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 4c395e39..75932082 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; + +import '../services/image_cache_service.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -576,8 +578,9 @@ class _MediaDetailScreenState extends State ), const SizedBox(width: 12), ], - // Download button (hide in offline mode - already downloaded) - if (!widget.isOffline) + // Download button (hide in offline mode - already downloaded, + // and on Apple TV where there's no user file storage). + if (!widget.isOffline && !PlatformDetector.isAppleTV()) Consumer( builder: (context, downloadProvider, _) { final globalKey = metadata.globalKey; @@ -2486,6 +2489,7 @@ class _MediaDetailScreenState extends State return blurArtwork( CachedNetworkImage( imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, fit: BoxFit.cover, placeholder: (context, url) => const PlaceholderContainer(), errorWidget: (context, url, error) => const PlaceholderContainer(), @@ -2575,6 +2579,7 @@ class _MediaDetailScreenState extends State return blurArtwork( CachedNetworkImage( imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, filterQuality: FilterQuality.medium, fit: BoxFit.contain, alignment: Alignment.centerLeft, diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 7a8b28c2..e68dc229 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -18,6 +18,7 @@ import 'playlist_item_card.dart'; import '../../i18n/strings.g.dart'; import '../../providers/download_provider.dart'; import '../../utils/content_utils.dart'; +import '../../utils/platform_detector.dart'; import '../../utils/dialogs.dart'; import '../../utils/download_utils.dart'; import '../../utils/global_key_utils.dart'; @@ -69,14 +70,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with FocusableTab { _buildTrackersTile(), // --- Downloads (inline) --- - _buildDownloadsSection(), + if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), // --- Keyboard Shortcuts (inline, conditional) --- if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], @@ -186,8 +186,8 @@ class _SettingsScreenState extends State with FocusableTab { // --- Updates (conditional) --- if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], - // --- Backup --- - _buildBackupSection(), + // --- Backup (hidden on Apple TV — no file picker / storage) --- + if (!PlatformDetector.isAppleTV()) _buildBackupSection(), // --- About --- ListTile( diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index 06528f5e..9e91f59e 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart' as http; +import '../utils/platform_detector.dart'; import '../utils/plex_http_client.dart'; /// Custom cache manager for Plex image transcoding with HTTP/2 multiplexing. @@ -16,15 +17,23 @@ class PlexImageCacheManager extends CacheManager with ImageCacheManager { static final PlexImageCacheManager instance = PlexImageCacheManager._(); - PlexImageCacheManager._() - : super( - Config( - _key, - stalePeriod: const Duration(days: 14), - maxNrOfCacheObjects: 3000, - fileService: _HttpFileService(httpClient.inner), - ), + PlexImageCacheManager._() : super(_buildConfig()); + + static Config _buildConfig() { + final fileService = _HttpFileService(httpClient.inner); + // tvOS has no sqflite plugin, so force the JSON cache-info repo there. + // On other platforms we let flutter_cache_manager pick its default repo. + if (PlatformDetector.isAppleTV()) { + return Config( + _key, + stalePeriod: const Duration(days: 14), + maxNrOfCacheObjects: 3000, + fileService: fileService, + repo: JsonCacheInfoRepository(databaseName: _key), ); + } + return Config(_key, stalePeriod: const Duration(days: 14), maxNrOfCacheObjects: 3000, fileService: fileService); + } } class _HttpFileService extends FileService { diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index bfefc65c..3708d977 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -4,12 +4,13 @@ import 'dart:math'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; -/// Service for detecting if the app is running on Android TV +/// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { static TvDetectionService? _instance; bool _detected = false; bool _forceTv = false; bool _isTV = false; + bool _isAppleTV = false; bool _initialized = false; TvDetectionService._(); @@ -24,20 +25,39 @@ class TvDetectionService { return _instance!; } + static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); + Future _detect(bool forceTv) async { if (_initialized) return; + final deviceInfo = DeviceInfoPlugin(); if (Platform.isAndroid) { - final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; - // Check for android.software.leanback feature (standard Android TV detection) _detected = androidInfo.systemFeatures.contains('android.software.leanback'); + } else if (Platform.isIOS) { + if (_tvosBuild) { + _isAppleTV = true; + _detected = true; + } else { + final iosInfo = await deviceInfo.iosInfo; + final sysName = iosInfo.systemName.toLowerCase(); + _isAppleTV = + sysName == 'tvos' || + sysName.contains('appletv') || + iosInfo.model.toLowerCase().contains('appletv') || + iosInfo.utsname.machine.toLowerCase().contains('appletv'); + _detected = _isAppleTV; + } } _forceTv = forceTv; _isTV = _detected || _forceTv; _initialized = true; } + /// True when running on Apple TV (tvOS). False for all other platforms + /// including force-TV on non-tvOS devices. + bool get isAppleTV => _isAppleTV; + bool get isTV => _isTV; /// Update the user force-TV override and recompute the effective flag. @@ -49,17 +69,27 @@ class TvDetectionService { /// Synchronous access after initialization (returns false if not initialized) static bool isTVSync() => _instance?._isTV ?? false; + /// Synchronous Apple TV check (returns false if not initialized or not tvOS). + static bool isAppleTVSync() => _instance?._isAppleTV ?? false; + /// Convenience setter that forwards to the singleton if available. static void setForceTVSync(bool value) => _instance?.setForceTv(value); } /// Utility class for platform detection class PlatformDetector { - /// Detects if running on Android TV (requires TvDetectionService to be initialized) + /// Detects if running on a TV platform (Android TV or Apple TV). + /// Requires TvDetectionService to be initialized. static bool isTV() { return TvDetectionService.isTVSync(); } + /// Detects if running specifically on Apple TV (tvOS). + /// Requires TvDetectionService to be initialized. + static bool isAppleTV() { + return TvDetectionService.isAppleTVSync(); + } + /// Detects if the app should use side navigation (Desktop or TV) static bool shouldUseSideNavigation(BuildContext context) { return isDesktop(context) || isTV(); @@ -71,9 +101,12 @@ class PlatformDetector { return isDesktop(context) || isTV(); } - /// Detects if running on a mobile platform (iOS or Android) - /// Uses Theme for consistent platform detection across the app + /// Detects if running on a mobile platform (iOS or Android). + /// Excludes TV platforms (Android TV / Apple TV) even though the underlying + /// OS is iOS or Android. + /// Uses Theme for consistent platform detection across the app. static bool isMobile(BuildContext context) { + if (isTV()) return false; final platform = Theme.of(context).platform; return platform == TargetPlatform.iOS || platform == TargetPlatform.android; } diff --git a/lib/utils/platform_http_client_io.dart b/lib/utils/platform_http_client_io.dart index 24f0e111..6136f606 100644 --- a/lib/utils/platform_http_client_io.dart +++ b/lib/utils/platform_http_client_io.dart @@ -31,8 +31,17 @@ http.Client createPlatformClient() { return CronetClient.fromCronetEngine(_sharedEngine!); } if (Platform.isIOS || Platform.isMacOS) { - _logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'CupertinoClient'); - return CupertinoClient.defaultSessionConfiguration(); + // cupertino_http relies on the objective_c FFI dylib, which isn't + // available on tvOS. Fall back to IOClient if the init fails. + try { + final client = CupertinoClient.defaultSessionConfiguration(); + _logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'CupertinoClient'); + return client; + } catch (e, st) { + appLogger.w('CupertinoClient init failed, falling back to IOClient', error: e, stackTrace: st); + _logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'IOClient (fallback)'); + return IOClient(); + } } if (Platform.isWindows) { try { diff --git a/lib/widgets/download_status_icon.dart b/lib/widgets/download_status_icon.dart index 0322f9e9..d8bac88c 100644 --- a/lib/widgets/download_status_icon.dart +++ b/lib/widgets/download_status_icon.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../models/download_models.dart'; +import '../utils/platform_detector.dart'; import '../widgets/app_icon.dart'; /// Visual weight preset. @@ -53,6 +54,7 @@ class DownloadStatusIcon extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlatformDetector.isAppleTV()) return const SizedBox.shrink(); final s = status; if (s == null) return const SizedBox.shrink(); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index caf3d46d..f107749e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -21,6 +21,7 @@ import '../providers/user_profile_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/library_refresh_notifier.dart'; +import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../utils/focus_utils.dart'; @@ -160,7 +161,7 @@ class MediaContextMenuState extends State { // Download + sync-rule management. Video playlists and any collection // qualify — collections can contain movies, episodes, and shows. final isVideoPlaylist = isPlaylist && (widget.item as PlexPlaylist).playlistType == 'video'; - if (isVideoPlaylist || isCollection) { + if ((isVideoPlaylist || isCollection) && !PlatformDetector.isAppleTV()) { final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemGlobalKey()); if (hasRule) { menuActions.add( @@ -324,11 +325,13 @@ class MediaContextMenuState extends State { ); } - // Download options (for episodes, movies, shows, and seasons) - if (mediaType == PlexMediaType.episode || - mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season) { + // Download options (for episodes, movies, shows, and seasons). + // Apple TV has no user-accessible file storage — skip entirely. + if (!PlatformDetector.isAppleTV() && + (mediaType == PlexMediaType.episode || + mediaType == PlexMediaType.movie || + mediaType == PlexMediaType.show || + mediaType == PlexMediaType.season)) { final downloadProvider = Provider.of(context, listen: false); final globalKey = metadata.globalKey; final isDownloaded = downloadProvider.isDownloaded(globalKey); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index ab2ed86e..84e0b8dd 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -13,6 +13,7 @@ import '../models/plex_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; +import '../utils/platform_detector.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; @@ -476,19 +477,21 @@ class SideNavigationRailState extends State { const SizedBox(height: 8), ], - // Downloads - _buildNavItem( - icon: Symbols.download_rounded, - selectedIcon: Symbols.download_rounded, - label: Translations.of(context).navigation.downloads, - isSelected: widget.selectedTab == NavigationTabId.downloads, - isFocused: _focusTracker.isFocused(_kDownloads), - onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), - focusNode: _focusTracker.get(_kDownloads), - isCollapsed: isCollapsed, - ), - - const SizedBox(height: 8), + // Downloads (hidden on Apple TV — no user + // file storage) + if (!PlatformDetector.isAppleTV()) ...[ + _buildNavItem( + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: widget.selectedTab == NavigationTabId.downloads, + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, + ), + const SizedBox(height: 8), + ], // Settings _buildNavItem( diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 3ab4f32d..302730e3 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -2,7 +2,7 @@ import Foundation import Libmpv import QuartzCore -#if os(iOS) +#if os(iOS) || os(tvOS) import UIKit #elseif os(macOS) import Cocoa @@ -26,6 +26,8 @@ class MpvMetalLayer: CAMetalLayer { } #if os(iOS) + // wantsExtendedDynamicRangeContent is unavailable on tvOS as of SDK 26.4, + // so this override only applies to iOS / macOS. @available(iOS 16.0, *) override var wantsExtendedDynamicRangeContent: Bool { get { super.wantsExtendedDynamicRangeContent } diff --git a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift index 3d396d72..115ad4b0 100644 --- a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift +++ b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift @@ -1,10 +1,10 @@ -#if os(iOS) +#if os(iOS) || os(tvOS) import Flutter #elseif os(macOS) import FlutterMacOS #endif -/// Protocol for shared MpvPlayerPlugin method handlers across iOS and macOS. +/// Protocol for shared MpvPlayerPlugin method handlers across iOS, tvOS, and macOS. /// Platform-specific methods (PiP, initialization, window finding) remain /// in the per-platform MpvPlayerPlugin files. protocol MpvPluginShared: AnyObject, MpvPlayerDelegate { diff --git a/tvos/.gitignore b/tvos/.gitignore new file mode 100644 index 00000000..6e658ed7 --- /dev/null +++ b/tvos/.gitignore @@ -0,0 +1,33 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +Flutter/GeneratedPluginRegistrant.* +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/tvos/Flutter/AppFrameworkInfo.plist b/tvos/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..8d4492f9 --- /dev/null +++ b/tvos/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/tvos/Flutter/Debug.xcconfig b/tvos/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/tvos/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/tvos/Flutter/Release.xcconfig b/tvos/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/tvos/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/tvos/Podfile b/tvos/Podfile new file mode 100644 index 00000000..92552ef6 --- /dev/null +++ b/tvos/Podfile @@ -0,0 +1,12 @@ +platform :tvos, '14.0' +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +target 'Runner' do + # No pods. +end diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..084b8b82 --- /dev/null +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,749 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0D69E2DEA6EA0538722BC7F0 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D0E84BD711F2744BA0D136 /* ConnectivityProvider.swift */; }; + 12E72ED3EFA35A8715DE2A52 /* GamepadStreamHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82813FF8CCC8320A8C32FC7 /* GamepadStreamHandler.swift */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 30E568153D56A59EA5EADCBB /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87984F20624EC6CA45E20790 /* PathProviderPlugin.swift */; }; + 32F0056AF154FC4EA76CBEFC /* GamepadPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD57BBEE1B72584C9C7D44B9 /* GamepadPlugin.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 477FB0F520224870902B9FD7 /* messages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FF71CD2416844A1FC485D77 /* messages.g.swift */; }; + 4A6DE7BDEF20A6242A3C6365 /* GCControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2071908D08983E6A6A1C1E /* GCControllerManager.swift */; }; + 5689B077C6EA57D53BBE8D0D /* OsMediaControlsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8663A3A20A0D6F446B6B37 /* OsMediaControlsPlugin.swift */; }; + 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; + 5CDBEB7E3FE968FC131E964A /* ConnectivityPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C667C1287EFBDF4DF45C5313 /* ConnectivityPlusPlugin.swift */; }; + 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; }; + 6A0510802F9B91CB0090B5FC /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; }; + 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8E5EED3DDAC9455D4DAA9776 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A7ED1D92E7B15D3F7562F93E /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09D436FC3E6CD994DC1B488 /* SharedPreferencesPlugin.swift */; }; + AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; }; + B494441CBB8FB05D04F55DD9 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4BADB53B8D59E031563AA9 /* DeviceInfoPlusPlugin.swift */; }; + BF599A1C25382688DD7F4505 /* ButtonMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FBF4BBA5D82316059225B32 /* ButtonMapping.swift */; }; + C1E0D7252DE079EDFA234F90 /* PathMonitorConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4716B74C5238614844BCA224 /* PathMonitorConnectivityProvider.swift */; }; + C330A1087AA135CCFC0C6203 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F435CB4590A55958615A7AD /* libPods-Runner.a */; }; + F36688C4E8DF44BC6BDD99A5 /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300783F8CE83D2530D2EDD97 /* PackageInfoPlusPlugin.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; + 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 = ""; }; + 0FF71CD2416844A1FC485D77 /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 20D0E84BD711F2744BA0D136 /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = ""; }; + 300783F8CE83D2530D2EDD97 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4716B74C5238614844BCA224 /* PathMonitorConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathMonitorConnectivityProvider.swift; sourceTree = ""; }; + 5C4BADB53B8D59E031563AA9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = ""; }; + 5F435CB4590A55958615A7AD /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5FBF4BBA5D82316059225B32 /* ButtonMapping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ButtonMapping.swift; sourceTree = ""; }; + 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 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 = ""; }; + 7B8663A3A20A0D6F446B6B37 /* OsMediaControlsPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OsMediaControlsPlugin.swift; sourceTree = ""; }; + 87984F20624EC6CA45E20790 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = ""; }; + 8F2071908D08983E6A6A1C1E /* GCControllerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GCControllerManager.swift; 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; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; + C667C1287EFBDF4DF45C5313 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = ""; }; + D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = ""; }; + D82813FF8CCC8320A8C32FC7 /* GamepadStreamHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GamepadStreamHandler.swift; sourceTree = ""; }; + DD57BBEE1B72584C9C7D44B9 /* GamepadPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GamepadPlugin.swift; sourceTree = ""; }; + E09D436FC3E6CD994DC1B488 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6A0510802F9B91CB0090B5FC /* MPVKit in Frameworks */, + C330A1087AA135CCFC0C6203 /* libPods-Runner.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0524C4A636652321237B0884 /* path_provider */ = { + isa = PBXGroup; + children = ( + 87984F20624EC6CA45E20790 /* PathProviderPlugin.swift */, + ); + path = path_provider; + sourceTree = ""; + }; + 08F5845B7875697970629696 /* package_info_plus */ = { + isa = PBXGroup; + children = ( + 300783F8CE83D2530D2EDD97 /* PackageInfoPlusPlugin.swift */, + ); + path = package_info_plus; + sourceTree = ""; + }; + 19DB3FBCF738894832DA07EF /* device_info_plus */ = { + isa = PBXGroup; + children = ( + 5C4BADB53B8D59E031563AA9 /* DeviceInfoPlusPlugin.swift */, + ); + path = device_info_plus; + sourceTree = ""; + }; + 53CDD29681161166962B9FA5 /* Pods */ = { + isa = PBXGroup; + children = ( + 0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */, + 04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */, + 420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 5815557FAD15751414018354 /* shared_preferences_foundation */ = { + isa = PBXGroup; + children = ( + E09D436FC3E6CD994DC1B488 /* SharedPreferencesPlugin.swift */, + 0FF71CD2416844A1FC485D77 /* messages.g.swift */, + ); + path = shared_preferences_foundation; + sourceTree = ""; + }; + 5833EC5B503BBB4E370BA1B7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5F435CB4590A55958615A7AD /* libPods-Runner.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 737C2C621E266B83D4011221 /* connectivity_plus */ = { + isa = PBXGroup; + children = ( + C667C1287EFBDF4DF45C5313 /* ConnectivityPlusPlugin.swift */, + 20D0E84BD711F2744BA0D136 /* ConnectivityProvider.swift */, + 4716B74C5238614844BCA224 /* PathMonitorConnectivityProvider.swift */, + ); + path = connectivity_plus; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 53CDD29681161166962B9FA5 /* Pods */, + 5833EC5B503BBB4E370BA1B7 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + BF0B7DFE378943E9D7865D18 /* MpvPlayer */, + E6BAF734A5201F83A1B61DCC /* Plugins */, + ); + path = Runner; + sourceTree = ""; + }; + AD5C822C9001042A47B99FBD /* universal_gamepad */ = { + isa = PBXGroup; + children = ( + DD57BBEE1B72584C9C7D44B9 /* GamepadPlugin.swift */, + D82813FF8CCC8320A8C32FC7 /* GamepadStreamHandler.swift */, + 8F2071908D08983E6A6A1C1E /* GCControllerManager.swift */, + 5FBF4BBA5D82316059225B32 /* ButtonMapping.swift */, + ); + path = universal_gamepad; + sourceTree = ""; + }; + BF0B7DFE378943E9D7865D18 /* MpvPlayer */ = { + isa = PBXGroup; + children = ( + D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */, + 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */, + A12B8610AE5D580077264851 /* MpvPlayerCore.swift */, + 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */, + A2635E12EB9322B151EE5127 /* MpvPipController.swift */, + ); + name = MpvPlayer; + path = Runner/MpvPlayer; + sourceTree = ""; + }; + C538704E1F003452B22FE0C7 /* os_media_controls */ = { + isa = PBXGroup; + children = ( + 7B8663A3A20A0D6F446B6B37 /* OsMediaControlsPlugin.swift */, + ); + path = os_media_controls; + sourceTree = ""; + }; + E6BAF734A5201F83A1B61DCC /* Plugins */ = { + isa = PBXGroup; + children = ( + 5815557FAD15751414018354 /* shared_preferences_foundation */, + 08F5845B7875697970629696 /* package_info_plus */, + 0524C4A636652321237B0884 /* path_provider */, + AD5C822C9001042A47B99FBD /* universal_gamepad */, + 19DB3FBCF738894832DA07EF /* device_info_plus */, + 737C2C621E266B83D4011221 /* connectivity_plus */, + C538704E1F003452B22FE0C7 /* os_media_controls */, + ); + path = Plugins; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 570D41C54A87D72AB27F6F15 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + EA0F4263E7B912702C490108 /* MPVKit */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + CEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo no-op thin binary\n"; + }; + 570D41C54A87D72AB27F6F15 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n/bin/sh \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" build\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */, + 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */, + 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */, + 8E5EED3DDAC9455D4DAA9776 /* MpvPlayerPlugin.swift in Sources */, + AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */, + A7ED1D92E7B15D3F7562F93E /* SharedPreferencesPlugin.swift in Sources */, + 477FB0F520224870902B9FD7 /* messages.g.swift in Sources */, + F36688C4E8DF44BC6BDD99A5 /* PackageInfoPlusPlugin.swift in Sources */, + 30E568153D56A59EA5EADCBB /* PathProviderPlugin.swift in Sources */, + 32F0056AF154FC4EA76CBEFC /* GamepadPlugin.swift in Sources */, + 12E72ED3EFA35A8715DE2A52 /* GamepadStreamHandler.swift in Sources */, + 4A6DE7BDEF20A6242A3C6365 /* GCControllerManager.swift in Sources */, + BF599A1C25382688DD7F4505 /* ButtonMapping.swift in Sources */, + B494441CBB8FB05D04F55DD9 /* DeviceInfoPlusPlugin.swift in Sources */, + 5CDBEB7E3FE968FC131E964A /* ConnectivityPlusPlugin.swift in Sources */, + 0D69E2DEA6EA0538722BC7F0 /* ConnectivityProvider.swift in Sources */, + C1E0D7252DE079EDFA234F90 /* PathMonitorConnectivityProvider.swift in Sources */, + 5689B077C6EA57D53BBE8D0D /* OsMediaControlsPlugin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.0; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Plezy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.0; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Plezy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Plezy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/edde746/MPVKit"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EA0F4263E7B912702C490108 /* MPVKit */ = { + isa = XCSwiftPackageProductDependency; + package = CEE842A5A25923620300AB90 /* XCRemoteSwiftPackageReference "MPVKit" */; + productName = MPVKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/tvos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/tvos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/tvos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..8597c517 --- /dev/null +++ b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee", + "pins" : [ + { + "identity" : "mpvkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/edde746/MPVKit", + "state" : { + "branch" : "main", + "revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3" + } + } + ], + "version" : 3 +} diff --git a/tvos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/tvos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..c5375023 --- /dev/null +++ b/tvos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tvos/Runner.xcworkspace/contents.xcworkspacedata b/tvos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/tvos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/tvos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/tvos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/tvos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/tvos/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/tvos/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/tvos/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..8597c517 --- /dev/null +++ b/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee", + "pins" : [ + { + "identity" : "mpvkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/edde746/MPVKit", + "state" : { + "branch" : "main", + "revision" : "29803ccbfa631997d496f34c2c1ed191cc8fd2c3" + } + } + ], + "version" : 3 +} diff --git a/tvos/Runner/AppDelegate.swift b/tvos/Runner/AppDelegate.swift new file mode 100644 index 00000000..ce11c546 --- /dev/null +++ b/tvos/Runner/AppDelegate.swift @@ -0,0 +1,67 @@ +import Flutter +import UIKit +import AVFoundation + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default) + try session.setActive(true) + } catch { + print("Failed to configure audio session: \(error)") + } + + application.beginReceivingRemoteControlEvents() + + NSLog("[tvos] AppDelegate didFinishLaunching — registering plugins") + + if let spRegistrar = self.registrar(forPlugin: "SharedPreferencesPlugin") { + NSLog("[tvos] Registering SharedPreferencesPlugin (Swift-direct)") + SharedPreferencesPlugin.register(with: spRegistrar) + } else { + NSLog("[tvos] SharedPreferencesPlugin registrar nil") + } + + if let mpvRegistrar = self.registrar(forPlugin: "MpvPlayerPlugin") { + NSLog("[tvos] Registering MpvPlayerPlugin") + MpvPlayerPlugin.register(with: mpvRegistrar) + } + + if let r = self.registrar(forPlugin: "PackageInfoPlusPlugin") { + NSLog("[tvos] Registering PackageInfoPlusPlugin") + PackageInfoPlusPlugin.register(with: r) + } + + if let r = self.registrar(forPlugin: "PathProviderPlugin") { + NSLog("[tvos] Registering PathProviderPlugin") + PathProviderPlugin.register(with: r) + } + + if let r = self.registrar(forPlugin: "GamepadPlugin") { + NSLog("[tvos] Registering GamepadPlugin") + GamepadPlugin.register(with: r) + } + + if let r = self.registrar(forPlugin: "DeviceInfoPlusPlugin") { + NSLog("[tvos] Registering DeviceInfoPlusPlugin") + DeviceInfoPlusPlugin.register(with: r) + } + + if let r = self.registrar(forPlugin: "ConnectivityPlusPlugin") { + NSLog("[tvos] Registering ConnectivityPlusPlugin") + ConnectivityPlusPlugin.register(with: r) + } + + if let r = self.registrar(forPlugin: "OsMediaControlsPlugin") { + NSLog("[tvos] Registering OsMediaControlsPlugin") + OsMediaControlsPlugin.register(with: r) + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png new file mode 100644 index 00000000..dde5f6c8 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..2ffe1f28 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Back.png", "idiom" : "tv" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 00000000..1447bbe2 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,8 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "layers" : [ + { "filename" : "Front.imagestacklayer" }, + { "filename" : "Middle.imagestacklayer" }, + { "filename" : "Back.imagestacklayer" } + ] +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..d64b60fb --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Front.png", "idiom" : "tv" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png new file mode 100644 index 00000000..c31d9ce5 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..6fb1c960 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Middle.png", "idiom" : "tv" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png new file mode 100644 index 00000000..db522e3e Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png new file mode 100644 index 00000000..6c611d22 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png new file mode 100644 index 00000000..1150c955 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..f4d31c5b --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Back.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Back@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 00000000..1447bbe2 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,8 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "layers" : [ + { "filename" : "Front.imagestacklayer" }, + { "filename" : "Middle.imagestacklayer" }, + { "filename" : "Back.imagestacklayer" } + ] +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..dba064f5 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Front.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Front@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png new file mode 100644 index 00000000..b1a2d8ac Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png new file mode 100644 index 00000000..9829d338 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..f4f61d7a --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Middle.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Middle@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png new file mode 100644 index 00000000..df6e8559 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png new file mode 100644 index 00000000..19be14b2 Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Contents.json new file mode 100644 index 00000000..9e732fb6 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Contents.json @@ -0,0 +1,9 @@ +{ + "assets" : [ + { "filename" : "App Icon.imagestack", "idiom" : "tv", "role" : "primary-app-icon", "size" : "400x240" }, + { "filename" : "App Icon - App Store.imagestack","idiom" : "tv", "role" : "primary-app-icon", "size" : "1280x768" }, + { "filename" : "Top Shelf Image.imageset", "idiom" : "tv", "role" : "top-shelf-image", "size" : "1920x720" }, + { "filename" : "Top Shelf Image Wide.imageset", "idiom" : "tv", "role" : "top-shelf-image-wide", "size" : "2320x720" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 00000000..be3fd87f --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "top-shelf-wide.png", "idiom" : "tv" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png new file mode 100644 index 00000000..0589915d Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/top-shelf-wide.png differ diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 00000000..c11917c5 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "top-shelf.png", "idiom" : "tv" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/top-shelf.png b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/top-shelf.png new file mode 100644 index 00000000..e2893d5a Binary files /dev/null and b/tvos/Runner/Assets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/top-shelf.png differ diff --git a/tvos/Runner/Assets.xcassets/Contents.json b/tvos/Runner/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/tvos/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/tvos/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/tvos/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/tvos/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/tvos/Runner/Base.lproj/LaunchScreen.storyboard b/tvos/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..7fe307be --- /dev/null +++ b/tvos/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tvos/Runner/Base.lproj/Main.storyboard b/tvos/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..67e5a3b4 --- /dev/null +++ b/tvos/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tvos/Runner/Info.plist b/tvos/Runner/Info.plist new file mode 100644 index 00000000..33705507 --- /dev/null +++ b/tvos/Runner/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Plezy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Plezy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + arm64 + + + diff --git a/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift b/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift new file mode 100644 index 00000000..b5d12115 --- /dev/null +++ b/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift @@ -0,0 +1,92 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source is governed by a BSD-style license that can +// be found in the LICENSE file. + +import Flutter + +public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private let connectivityProvider: ConnectivityProvider + private var eventSink: FlutterEventSink? + + init(connectivityProvider: ConnectivityProvider) { + self.connectivityProvider = connectivityProvider + super.init() + self.connectivityProvider.connectivityUpdateHandler = connectivityUpdateHandler + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let binaryMessenger = registrar.messenger() + + let channel = FlutterMethodChannel( + name: "dev.fluttercommunity.plus/connectivity", + binaryMessenger: binaryMessenger) + + let streamChannel = FlutterEventChannel( + name: "dev.fluttercommunity.plus/connectivity_status", + binaryMessenger: binaryMessenger) + + let connectivityProvider = PathMonitorConnectivityProvider() + let instance = ConnectivityPlusPlugin(connectivityProvider: connectivityProvider) + streamChannel.setStreamHandler(instance) + + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + eventSink = nil + connectivityProvider.stop() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "check": + result(statusFrom(connectivityTypes: connectivityProvider.currentConnectivityTypes)) + default: + result(FlutterMethodNotImplemented) + } + } + + private func statusFrom(connectivityType: ConnectivityType) -> String { + switch connectivityType { + case .wifi: + return "wifi" + case .cellular: + return "mobile" + case .wiredEthernet: + return "ethernet" + case .other: + return "other" + case .none: + return "none" + } + } + + private func statusFrom(connectivityTypes: [ConnectivityType]) -> [String] { + return connectivityTypes.map { + self.statusFrom(connectivityType: $0) + } + } + + public func onListen( + withArguments _: Any?, + eventSink events: @escaping FlutterEventSink + ) -> FlutterError? { + eventSink = events + connectivityProvider.start() + // Update this to handle a list + connectivityUpdateHandler(connectivityTypes: connectivityProvider.currentConnectivityTypes) + return nil + } + + private func connectivityUpdateHandler(connectivityTypes: [ConnectivityType]) { + DispatchQueue.main.async { + self.eventSink?(self.statusFrom(connectivityTypes: connectivityTypes)) + } + } + + public func onCancel(withArguments _: Any?) -> FlutterError? { + connectivityProvider.stop() + eventSink = nil + return nil + } +} diff --git a/tvos/Runner/Plugins/connectivity_plus/ConnectivityProvider.swift b/tvos/Runner/Plugins/connectivity_plus/ConnectivityProvider.swift new file mode 100644 index 00000000..ad2cbc1e --- /dev/null +++ b/tvos/Runner/Plugins/connectivity_plus/ConnectivityProvider.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum ConnectivityType { + case none + case wiredEthernet + case wifi + case cellular + case other +} + +public protocol ConnectivityProvider: NSObjectProtocol { + typealias ConnectivityUpdateHandler = ([ConnectivityType]) -> Void + + var currentConnectivityTypes: [ConnectivityType] { get } + + var connectivityUpdateHandler: ConnectivityUpdateHandler? { get set } + + func start() + + func stop() +} diff --git a/tvos/Runner/Plugins/connectivity_plus/PathMonitorConnectivityProvider.swift b/tvos/Runner/Plugins/connectivity_plus/PathMonitorConnectivityProvider.swift new file mode 100644 index 00000000..d7a8252b --- /dev/null +++ b/tvos/Runner/Plugins/connectivity_plus/PathMonitorConnectivityProvider.swift @@ -0,0 +1,69 @@ +import Foundation +import Network + +public class PathMonitorConnectivityProvider: NSObject, ConnectivityProvider { + + // Use .utility, as it is intended for tasks that the user does not track actively. + // See: https://developer.apple.com/documentation/dispatch/dispatchqos + private let queue = DispatchQueue.global(qos: .utility) + + private var pathMonitor: NWPathMonitor? + + private func connectivityFrom(path: NWPath) -> [ConnectivityType] { + var types: [ConnectivityType] = [] + + // Check for connectivity and append to types array as necessary + if path.status == .satisfied { + if path.usesInterfaceType(.wifi) { + types.append(.wifi) + } + if path.usesInterfaceType(.cellular) { + types.append(.cellular) + } + if path.usesInterfaceType(.wiredEthernet) { + types.append(.wiredEthernet) + } + if path.usesInterfaceType(.other) { + types.append(.other) + } + } + + return types.isEmpty ? [.none] : types + } + + public var currentConnectivityTypes: [ConnectivityType] { + let path = ensurePathMonitor().currentPath + return connectivityFrom(path: path) + } + + public var connectivityUpdateHandler: ConnectivityUpdateHandler? + + override init() { + super.init() + _ = ensurePathMonitor() + } + + public func start() { + _ = ensurePathMonitor() + } + + public func stop() { + pathMonitor?.cancel() + pathMonitor = nil + } + + @discardableResult + private func ensurePathMonitor() -> NWPathMonitor { + if (pathMonitor == nil) { + let pathMonitor = NWPathMonitor() + pathMonitor.start(queue: queue) + pathMonitor.pathUpdateHandler = pathUpdateHandler + self.pathMonitor = pathMonitor + } + return self.pathMonitor! + } + + private func pathUpdateHandler(path: NWPath) { + connectivityUpdateHandler?(connectivityFrom(path: path)) + } +} diff --git a/tvos/Runner/Plugins/device_info_plus/DeviceInfoPlusPlugin.swift b/tvos/Runner/Plugins/device_info_plus/DeviceInfoPlusPlugin.swift new file mode 100644 index 00000000..afda3510 --- /dev/null +++ b/tvos/Runner/Plugins/device_info_plus/DeviceInfoPlusPlugin.swift @@ -0,0 +1,119 @@ +// Pure-Swift tvOS port of fluttercommunity.plus/device_info. Mirrors the +// Objective-C FPPDeviceInfoPlusPlugin on iOS so the Dart IosDeviceInfo +// parser finds all the keys it expects. + +import Foundation +import UIKit + +#if os(iOS) || os(tvOS) + import Flutter + + public class DeviceInfoPlusPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "dev.fluttercommunity.plus/device_info", + binaryMessenger: registrar.messenger() + ) + let instance = DeviceInfoPlusPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "getDeviceInfo" else { + result(FlutterMethodNotImplemented) + return + } + + let device = UIDevice.current + var uts = utsname() + uname(&uts) + + let processInfo = ProcessInfo.processInfo + let isSimulator: Bool = { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + }() + let isPhysicalDevice = !isSimulator + + let machine: String + if isPhysicalDevice { + machine = Self.utsnameString(&uts.machine) + } else { + machine = processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "" + } + + let freeDisk: Int64 + let totalDisk: Int64 + if let attrs = try? FileManager.default.attributesOfFileSystem( + forPath: NSHomeDirectory()) + { + freeDisk = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? -1 + totalDisk = (attrs[.systemSize] as? NSNumber)?.int64Value ?? -1 + } else { + freeDisk = -1 + totalDisk = -1 + } + + var isiOSAppOnMac = false + if #available(iOS 14.0, tvOS 14.0, *) { + isiOSAppOnMac = processInfo.isiOSAppOnMac + } + + let physicalRam = Int64(processInfo.physicalMemory) / 1_048_576 + + let info: [String: Any] = [ + "name": device.name, + "systemName": device.systemName, + "systemVersion": device.systemVersion, + "model": device.model, + "localizedModel": device.localizedModel, + "modelName": machine, + "identifierForVendor": device.identifierForVendor?.uuidString as Any, + "freeDiskSize": freeDisk, + "totalDiskSize": totalDisk, + "isPhysicalDevice": isPhysicalDevice, + "isiOSAppOnMac": isiOSAppOnMac, + "physicalRamSize": physicalRam, + "availableRamSize": Self.availableMemoryMB(), + "utsname": [ + "sysname": Self.utsnameString(&uts.sysname), + "nodename": Self.utsnameString(&uts.nodename), + "release": Self.utsnameString(&uts.release), + "version": Self.utsnameString(&uts.version), + "machine": machine, + ], + ] + + result(info) + } + + private static func utsnameString(_ tupled: inout T) -> String { + withUnsafePointer(to: &tupled) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size) { + String(cString: $0) + } + } + } + + private static func availableMemoryMB() -> Int { + var hostInfo = vm_statistics_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + let host = mach_host_self() + var pageSize: vm_size_t = 0 + host_page_size(host, &pageSize) + + let result: kern_return_t = withUnsafeMutablePointer(to: &hostInfo) { ptr in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPtr in + host_statistics(host, HOST_VM_INFO, reboundPtr, &count) + } + } + guard result == KERN_SUCCESS else { return -1 } + let memFree = UInt64(hostInfo.free_count) * UInt64(pageSize) + return Int(memFree / 1_048_576) + } + } +#endif diff --git a/tvos/Runner/Plugins/os_media_controls/OsMediaControlsPlugin.swift b/tvos/Runner/Plugins/os_media_controls/OsMediaControlsPlugin.swift new file mode 100644 index 00000000..d3c3c087 --- /dev/null +++ b/tvos/Runner/Plugins/os_media_controls/OsMediaControlsPlugin.swift @@ -0,0 +1,370 @@ +import Flutter +import UIKit +import MediaPlayer +import AVFoundation + +public class OsMediaControlsPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private let nowPlayingCenter = MPNowPlayingInfoCenter.default() + private let commandCenter = MPRemoteCommandCenter.shared() + + private var currentMetadata: [String: Any] = [:] + + + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel( + name: "com.edde746.os_media_controls/methods", + binaryMessenger: registrar.messenger() + ) + let eventChannel = FlutterEventChannel( + name: "com.edde746.os_media_controls/events", + binaryMessenger: registrar.messenger() + ) + + let instance = OsMediaControlsPlugin() + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + } + + public override init() { + super.init() + + // Ensure app receives remote control events for Now Playing controls + DispatchQueue.main.async { + UIApplication.shared.beginReceivingRemoteControlEvents() + } + + setupRemoteCommandCenter() + } + + private func setupRemoteCommandCenter() { + // Play command + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { [weak self] event in + self?.sendEvent(["type": "play"]) + return .success + } + + // Pause command + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { [weak self] event in + self?.sendEvent(["type": "pause"]) + return .success + } + + // Toggle play/pause command + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in + self?.sendEvent(["type": "togglePlayPause"]) + return .success + } + + // Next track command + commandCenter.nextTrackCommand.isEnabled = true + commandCenter.nextTrackCommand.addTarget { [weak self] event in + self?.sendEvent(["type": "next"]) + return .success + } + + // Previous track command + commandCenter.previousTrackCommand.isEnabled = true + commandCenter.previousTrackCommand.addTarget { [weak self] event in + self?.sendEvent(["type": "previous"]) + return .success + } + + // Change playback position command (seek) + commandCenter.changePlaybackPositionCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in + if let positionEvent = event as? MPChangePlaybackPositionCommandEvent { + self?.sendEvent([ + "type": "seek", + "position": positionEvent.positionTime + ]) + } + return .success + } + + // Skip forward command + commandCenter.skipForwardCommand.isEnabled = false // Disabled by default + commandCenter.skipForwardCommand.addTarget { [weak self] event in + if let skipEvent = event as? MPSkipIntervalCommandEvent { + self?.sendEvent([ + "type": "skipForward", + "interval": skipEvent.interval + ]) + } else { + self?.sendEvent(["type": "skipForward"]) + } + return .success + } + + // Skip backward command + commandCenter.skipBackwardCommand.isEnabled = false // Disabled by default + commandCenter.skipBackwardCommand.addTarget { [weak self] event in + if let skipEvent = event as? MPSkipIntervalCommandEvent { + self?.sendEvent([ + "type": "skipBackward", + "interval": skipEvent.interval + ]) + } else { + self?.sendEvent(["type": "skipBackward"]) + } + return .success + } + + // Change playback rate command + commandCenter.changePlaybackRateCommand.isEnabled = true + commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 1.0, 1.5, 2.0] + commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in + if let rateEvent = event as? MPChangePlaybackRateCommandEvent { + self?.sendEvent([ + "type": "setSpeed", + "speed": rateEvent.playbackRate + ]) + } + return .success + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "setMetadata": + setMetadata(arguments: call.arguments as? [String: Any]) + result(nil) + + case "setPlaybackState": + setPlaybackState(arguments: call.arguments as? [String: Any]) + result(nil) + + case "enableControls": + enableControls(arguments: call.arguments as? [String]) + result(nil) + + case "disableControls": + disableControls(arguments: call.arguments as? [String]) + result(nil) + + case "setSkipIntervals": + setSkipIntervals(arguments: call.arguments as? [String: Any]) + result(nil) + + case "setQueueInfo": + setQueueInfo(arguments: call.arguments as? [String: Any]) + result(nil) + + case "clear": + clear() + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func setMetadata(arguments: [String: Any]?) { + guard let args = arguments else { return } + + // Reactivate audio session to ensure media controls work after being cleared + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to reactivate audio session: \(error)") + } + + // Store metadata for later use + for (key, value) in args { + if key != "artwork" { + currentMetadata[key] = value + } + } + + var nowPlayingInfo = nowPlayingCenter.nowPlayingInfo ?? [:] + + if let title = args["title"] as? String { + nowPlayingInfo[MPMediaItemPropertyTitle] = title + } + if let artist = args["artist"] as? String { + nowPlayingInfo[MPMediaItemPropertyArtist] = artist + } + if let album = args["album"] as? String { + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album + } + if let albumArtist = args["albumArtist"] as? String { + nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = albumArtist + } + if let duration = args["duration"] as? Double { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration + } + // Handle artwork from bytes (takes precedence over URL) + if let artworkData = args["artwork"] as? FlutterStandardTypedData { + if let image = UIImage(data: artworkData.data) { + nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } + } else if let artworkUrlString = args["artworkUrl"] as? String, let artworkUrl = URL(string: artworkUrlString) { + // Handle artwork from URL - download asynchronously + URLSession.shared.dataTask(with: artworkUrl) { [weak self] data, response, error in + guard let self = self else { return } + guard error == nil, let data = data, let image = UIImage(data: data) else { + return + } + + DispatchQueue.main.async { + var updatedInfo = self.nowPlayingCenter.nowPlayingInfo ?? [:] + updatedInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + self.nowPlayingCenter.nowPlayingInfo = updatedInfo + } + }.resume() + } + + nowPlayingCenter.nowPlayingInfo = nowPlayingInfo + } + + private func setPlaybackState(arguments: [String: Any]?) { + guard let args = arguments, + let stateString = args["state"] as? String, + let position = args["position"] as? Double, + let speed = args["speed"] as? Double else { return } + + // Reactivate audio session to ensure media controls work after being cleared + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to reactivate audio session: \(error)") + } + + var nowPlayingInfo = nowPlayingCenter.nowPlayingInfo ?? [:] + + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = position + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = + stateString == "playing" ? speed : 0.0 + + nowPlayingCenter.nowPlayingInfo = nowPlayingInfo + } + + private func enableControls(arguments: [String]?) { + guard let controls = arguments else { return } + + for control in controls { + switch control { + case "play": + commandCenter.playCommand.isEnabled = true + case "pause": + commandCenter.pauseCommand.isEnabled = true + case "stop": + // iOS doesn't have a dedicated stop command + break + case "next": + commandCenter.nextTrackCommand.isEnabled = true + case "previous": + commandCenter.previousTrackCommand.isEnabled = true + case "seek": + commandCenter.changePlaybackPositionCommand.isEnabled = true + case "skipForward": + commandCenter.skipForwardCommand.isEnabled = true + case "skipBackward": + commandCenter.skipBackwardCommand.isEnabled = true + case "changeSpeed": + commandCenter.changePlaybackRateCommand.isEnabled = true + default: + break + } + } + } + + private func disableControls(arguments: [String]?) { + guard let controls = arguments else { return } + + for control in controls { + switch control { + case "play": + commandCenter.playCommand.isEnabled = false + case "pause": + commandCenter.pauseCommand.isEnabled = false + case "next": + commandCenter.nextTrackCommand.isEnabled = false + case "previous": + commandCenter.previousTrackCommand.isEnabled = false + case "seek": + commandCenter.changePlaybackPositionCommand.isEnabled = false + case "skipForward": + commandCenter.skipForwardCommand.isEnabled = false + case "skipBackward": + commandCenter.skipBackwardCommand.isEnabled = false + case "changeSpeed": + commandCenter.changePlaybackRateCommand.isEnabled = false + default: + break + } + } + } + + private func setSkipIntervals(arguments: [String: Any]?) { + guard let args = arguments else { return } + + if let forward = args["forward"] as? Int { + commandCenter.skipForwardCommand.isEnabled = true + commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: forward)] + } + + if let backward = args["backward"] as? Int { + commandCenter.skipBackwardCommand.isEnabled = true + commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: backward)] + } + } + + private func setQueueInfo(arguments: [String: Any]?) { + guard let args = arguments, + let currentIndex = args["currentIndex"] as? Int, + let queueLength = args["queueLength"] as? Int else { return } + + var nowPlayingInfo = nowPlayingCenter.nowPlayingInfo ?? [:] + + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackQueueIndex] = currentIndex + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackQueueCount] = queueLength + + nowPlayingCenter.nowPlayingInfo = nowPlayingInfo + } + + private func clear() { + nowPlayingCenter.nowPlayingInfo = nil + currentMetadata.removeAll() + + // Deactivate audio session to force iOS to remove controls from Control Center + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + // Audio session deactivation failed, but continue with cleanup + } + + // Disable all command center buttons + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.playCommand.isEnabled = false + commandCenter.pauseCommand.isEnabled = false + commandCenter.togglePlayPauseCommand.isEnabled = false + commandCenter.nextTrackCommand.isEnabled = false + commandCenter.previousTrackCommand.isEnabled = false + commandCenter.changePlaybackPositionCommand.isEnabled = false + commandCenter.skipForwardCommand.isEnabled = false + commandCenter.skipBackwardCommand.isEnabled = false + commandCenter.changePlaybackRateCommand.isEnabled = false + } + + + private func sendEvent(_ event: [String: Any]) { + eventSink?(event) + } + + // MARK: - FlutterStreamHandler + + public func onListen(withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} diff --git a/tvos/Runner/Plugins/package_info_plus/PackageInfoPlusPlugin.swift b/tvos/Runner/Plugins/package_info_plus/PackageInfoPlusPlugin.swift new file mode 100644 index 00000000..4b0b2cfd --- /dev/null +++ b/tvos/Runner/Plugins/package_info_plus/PackageInfoPlusPlugin.swift @@ -0,0 +1,82 @@ +// Pure-Swift tvOS port of fluttercommunity.plus/package_info. +// Matches the Objective-C FPPPackageInfoPlusPlugin on iOS exactly enough to +// satisfy PackageInfo.fromPlatform() in Dart. + +import Foundation + +#if os(iOS) || os(tvOS) + import Flutter + + public class PackageInfoPlusPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "dev.fluttercommunity.plus/package_info", + binaryMessenger: registrar.messenger() + ) + let instance = PackageInfoPlusPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "getAll" else { + result(FlutterMethodNotImplemented) + return + } + + let bundle = Bundle.main + let appStoreReceipt = bundle.appStoreReceiptURL?.path ?? "" + let installerStore: String + if appStoreReceipt.contains("CoreSimulator") { + installerStore = "com.apple.simulator" + } else if appStoreReceipt.contains("sandboxReceipt") { + installerStore = "com.apple.testflight" + } else { + installerStore = "com.apple" + } + + let appName = + (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? "" + let packageName = bundle.bundleIdentifier ?? "" + let version = + (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "" + let buildNumber = (bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String) ?? "" + + let installTime = Self.timeMillisString(from: Self.installDate()) + let updateTime = Self.timeMillisString(from: Self.updateDate()) + + result([ + "appName": appName, + "packageName": packageName, + "version": version, + "buildNumber": buildNumber, + "installerStore": installerStore, + "installTime": installTime as Any, + "updateTime": updateTime as Any, + ]) + } + + private static func installDate() -> Date? { + guard + let docsURL = FileManager.default.urls( + for: .documentDirectory, in: .userDomainMask + ).last + else { + return nil + } + let attrs = try? FileManager.default.attributesOfItem(atPath: docsURL.path) + return attrs?[.creationDate] as? Date + } + + private static func updateDate() -> Date? { + let attrs = try? FileManager.default.attributesOfItem(atPath: Bundle.main.bundlePath) + return attrs?[.modificationDate] as? Date + } + + private static func timeMillisString(from date: Date?) -> String? { + guard let date = date else { return nil } + return String(Int64(date.timeIntervalSince1970 * 1000)) + } + } +#endif diff --git a/tvos/Runner/Plugins/path_provider/PathProviderPlugin.swift b/tvos/Runner/Plugins/path_provider/PathProviderPlugin.swift new file mode 100644 index 00000000..380cc2fe --- /dev/null +++ b/tvos/Runner/Plugins/path_provider/PathProviderPlugin.swift @@ -0,0 +1,85 @@ +// Pure-Swift tvOS implementation of the legacy plugins.flutter.io/path_provider +// channel. Replaces path_provider_foundation's FFI-based Dart impl, which +// requires the objective_c package's dylib and isn't linked on tvOS. + +import Foundation + +#if os(iOS) || os(tvOS) + import Flutter + + public class PathProviderPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/path_provider", + binaryMessenger: registrar.messenger() + ) + let instance = PathProviderPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // On tvOS apps have no persistent writable storage; only the Caches + // directory is writable (and can be evicted anytime). Docs, Support, and + // Library all return dummy paths that fail on write. Route them into + // Caches subdirectories so plugins expecting a writable path work. + #if os(tvOS) + let tvosCache = pathIn(.cachesDirectory) + func tvosSubdir(_ name: String) -> String? { + guard let base = tvosCache else { return nil } + let p = (base as NSString).appendingPathComponent(name) + try? FileManager.default.createDirectory( + atPath: p, withIntermediateDirectories: true) + return p + } + + switch call.method { + case "getTemporaryDirectory": + result(tvosCache) + case "getApplicationSupportDirectory": + result(tvosSubdir("ApplicationSupport")) + case "getApplicationDocumentsDirectory": + result(tvosSubdir("Documents")) + case "getApplicationCacheDirectory": + result(tvosSubdir("AppCache")) + case "getLibraryDirectory": + result(tvosSubdir("Library")) + case "getDownloadsDirectory": + result(tvosSubdir("Downloads")) + default: + result(FlutterMethodNotImplemented) + } + #else + switch call.method { + case "getTemporaryDirectory": + result(pathIn(.cachesDirectory)) + case "getApplicationSupportDirectory": + let path = pathIn(.applicationSupportDirectory) + if let p = path { + try? FileManager.default.createDirectory( + atPath: p, withIntermediateDirectories: true) + } + result(path) + case "getApplicationDocumentsDirectory": + result(pathIn(.documentDirectory)) + case "getApplicationCacheDirectory": + let path = pathIn(.cachesDirectory) + if let p = path { + try? FileManager.default.createDirectory( + atPath: p, withIntermediateDirectories: true) + } + result(path) + case "getLibraryDirectory": + result(pathIn(.libraryDirectory)) + case "getDownloadsDirectory": + result(pathIn(.downloadsDirectory)) + default: + result(FlutterMethodNotImplemented) + } + #endif + } + + private func pathIn(_ directory: FileManager.SearchPathDirectory) -> String? { + NSSearchPathForDirectoriesInDomains(directory, .userDomainMask, true).first + } + } +#endif diff --git a/tvos/Runner/Plugins/shared_preferences_foundation/SharedPreferencesPlugin.swift b/tvos/Runner/Plugins/shared_preferences_foundation/SharedPreferencesPlugin.swift new file mode 100644 index 00000000..37f135f0 --- /dev/null +++ b/tvos/Runner/Plugins/shared_preferences_foundation/SharedPreferencesPlugin.swift @@ -0,0 +1,194 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Foundation + +#if os(iOS) || os(tvOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#endif + +let argumentError: String = "Argument Error" + +public class LegacySharedPreferencesPlugin: NSObject, FlutterPlugin, LegacyUserDefaultsApi { + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = LegacySharedPreferencesPlugin() + // Workaround for https://github.com/flutter/flutter/issues/118103. + #if os(iOS) || os(tvOS) + let messenger = registrar.messenger() + #else + let messenger = registrar.messenger + #endif + LegacyUserDefaultsApiSetup.setUp(binaryMessenger: messenger, api: instance) + } + + func getAll(prefix: String, allowList: [String]?) -> [String: Any] { + return getAllPrefs(prefix: prefix, allowList: allowList) + } + + func setBool(key: String, value: Bool) { + UserDefaults.standard.set(value, forKey: key) + } + + func setDouble(key: String, value: Double) { + UserDefaults.standard.set(value, forKey: key) + } + + func setValue(key: String, value: Any) { + UserDefaults.standard.set(value, forKey: key) + } + + func remove(key: String) { + UserDefaults.standard.removeObject(forKey: key) + } + + func clear(prefix: String, allowList: [String]?) -> Bool { + let defaults = UserDefaults.standard + for (key, _) in getAllPrefs(prefix: prefix, allowList: allowList) { + defaults.removeObject(forKey: key) + } + return true + } + + /// Returns all preferences stored with specified prefix. + /// If [allowList] is included, only items included will be returned. + func getAllPrefs(prefix: String, allowList: [String]?) -> [String: Any] { + var filteredPrefs: [String: Any] = [:] + + let prefs = try! SharedPreferencesPlugin.getAllPrefs( + allowList: allowList, options: SharedPreferencesPigeonOptions()) + + for (key, value) in prefs where (key.hasPrefix(prefix)) { + filteredPrefs[key] = value + } + + return filteredPrefs + } + +} + +public class SharedPreferencesPlugin: NSObject, FlutterPlugin, UserDefaultsApi { + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = SharedPreferencesPlugin() + // Workaround for https://github.com/flutter/flutter/issues/118103. + #if os(iOS) || os(tvOS) + let messenger = registrar.messenger() + #else + let messenger = registrar.messenger + #endif + UserDefaultsApiSetup.setUp(binaryMessenger: messenger, api: instance) + LegacySharedPreferencesPlugin.register(with: registrar) + } + + static private func getUserDefaults(options: SharedPreferencesPigeonOptions) throws + -> UserDefaults + { + #if os(iOS) + if !(options.suiteName?.starts(with: "group.") ?? true) { + throw FlutterError( + code: argumentError, + message: + "The provided Suite Name '\(options.suiteName!)' does not follow the predefined requirements", + details: "") as! Error + } + #endif + let prefs = UserDefaults(suiteName: options.suiteName) + + if prefs == nil { + throw FlutterError( + code: argumentError, + message: "The provided Suite Name '\(options.suiteName!)' does not exist", + details: "") as! Error + } + return prefs! + } + + func getKeys(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws -> [String] { + return Array(try getAll(allowList: allowList, options: options).keys) + } + + func getAll(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws -> [String: Any] + { + return try SharedPreferencesPlugin.getAllPrefs(allowList: allowList, options: options) + } + + func set(key: String, value: Any, options: SharedPreferencesPigeonOptions) throws { + try SharedPreferencesPlugin.getUserDefaults(options: options).set(value, forKey: key) + } + + func getValue(key: String, options: SharedPreferencesPigeonOptions) throws -> Any? { + let preference = try SharedPreferencesPlugin.getUserDefaults(options: options).object( + forKey: key) + return SharedPreferencesPlugin.isTypeCompatible(value: preference as Any) ? preference : nil + } + + func remove(key: String, options: SharedPreferencesPigeonOptions) throws { + try SharedPreferencesPlugin.getUserDefaults(options: options).removeObject(forKey: key) + } + + func clear(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws { + let defaults = try SharedPreferencesPlugin.getUserDefaults(options: options) + if let allowList = allowList { + for (key) in allowList { + defaults.removeObject(forKey: key) + } + } else { + for key in defaults.dictionaryRepresentation().keys { + defaults.removeObject(forKey: key) + } + } + } + + /// Returns all preferences stored with specified prefix. + /// If [allowList] is included, only items included will be returned. + /// If no [allowList], returns supported types only. + static func getAllPrefs(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws + -> [String: Any] + { + var filteredPrefs: [String: Any] = [:] + var compatiblePrefs: [String: Any] = [:] + let allowSet = allowList.map { Set($0) } + + // Since `getUserDefaults` is initialized with the suite name, it seems redundant to call + // `persistentDomain` with the suite name again. However, it is necessary because + // `dictionaryRepresentation` returns keys from the global domain. + // Also, Apple's docs on `persistentDomain` are incorrect, + // see: https://github.com/feedback-assistant/reports/issues/165 + if let appDomain = options.suiteName ?? Bundle.main.bundleIdentifier, + let prefs = try getUserDefaults(options: options).persistentDomain(forName: appDomain) + { + if let allowSet = allowSet { + filteredPrefs = prefs.filter { allowSet.contains($0.key) } + } else { + filteredPrefs = prefs + } + compatiblePrefs = filteredPrefs.filter { isTypeCompatible(value: $0.value) } + } + return compatiblePrefs + } + + static func isTypeCompatible(value: Any) -> Bool { + switch value { + case is Bool: + return true + case is Double: + return true + case is String: + return true + case is Int: + return true + case is [Any]: + if let value = value as? [Any] { + return value.allSatisfy(isTypeCompatible) + } + default: + return false + } + return false + } + +} diff --git a/tvos/Runner/Plugins/shared_preferences_foundation/messages.g.swift b/tvos/Runner/Plugins/shared_preferences_foundation/messages.g.swift new file mode 100644 index 00000000..5c019a7c --- /dev/null +++ b/tvos/Runner/Plugins/shared_preferences_foundation/messages.g.swift @@ -0,0 +1,452 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// Autogenerated from Pigeon (v26.1.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) || os(tvOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsmessages(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsmessages(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsmessages(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashmessages(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashmessages(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashmessages(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SharedPreferencesPigeonOptions: Hashable { + var suiteName: String? = nil + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SharedPreferencesPigeonOptions? { + let suiteName: String? = nilOrValue(pigeonVar_list[0]) + + return SharedPreferencesPigeonOptions( + suiteName: suiteName + ) + } + func toList() -> [Any?] { + return [ + suiteName + ] + } + static func == (lhs: SharedPreferencesPigeonOptions, rhs: SharedPreferencesPigeonOptions) -> Bool + { + return deepEqualsmessages(lhs.toList(), rhs.toList()) + } + func hash(into hasher: inout Hasher) { + deepHashmessages(value: toList(), hasher: &hasher) + } +} + +private class MessagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return SharedPreferencesPigeonOptions.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class MessagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? SharedPreferencesPigeonOptions { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return MessagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return MessagesPigeonCodecWriter(data: data) + } +} + +class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol LegacyUserDefaultsApi { + func remove(key: String) throws + func setBool(key: String, value: Bool) throws + func setDouble(key: String, value: Double) throws + func setValue(key: String, value: Any) throws + func getAll(prefix: String, allowList: [String]?) throws -> [String: Any] + func clear(prefix: String, allowList: [String]?) throws -> Bool +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class LegacyUserDefaultsApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `LegacyUserDefaultsApi` to handle messages through the `binaryMessenger`. + static func setUp( + binaryMessenger: FlutterBinaryMessenger, api: LegacyUserDefaultsApi?, + messageChannelSuffix: String = "" + ) { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let removeChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.remove\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + removeChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + do { + try api.remove(key: keyArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + removeChannel.setMessageHandler(nil) + } + let setBoolChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.setBool\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setBoolChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + let valueArg = args[1] as! Bool + do { + try api.setBool(key: keyArg, value: valueArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setBoolChannel.setMessageHandler(nil) + } + let setDoubleChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.setDouble\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setDoubleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + let valueArg = args[1] as! Double + do { + try api.setDouble(key: keyArg, value: valueArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setDoubleChannel.setMessageHandler(nil) + } + let setValueChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.setValue\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setValueChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + let valueArg = args[1]! + do { + try api.setValue(key: keyArg, value: valueArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setValueChannel.setMessageHandler(nil) + } + let getAllChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.getAll\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getAllChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let prefixArg = args[0] as! String + let allowListArg: [String]? = nilOrValue(args[1]) + do { + let result = try api.getAll(prefix: prefixArg, allowList: allowListArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAllChannel.setMessageHandler(nil) + } + let clearChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.LegacyUserDefaultsApi.clear\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let prefixArg = args[0] as! String + let allowListArg: [String]? = nilOrValue(args[1]) + do { + let result = try api.clear(prefix: prefixArg, allowList: allowListArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + clearChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol UserDefaultsApi { + /// Adds property to shared preferences data set of type String. + func set(key: String, value: Any, options: SharedPreferencesPigeonOptions) throws + /// Removes all properties from shared preferences data set with matching prefix. + func clear(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws + /// Gets all properties from shared preferences data set with matching prefix. + func getAll(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws -> [String: Any] + /// Gets individual value stored with [key], if any. + func getValue(key: String, options: SharedPreferencesPigeonOptions) throws -> Any? + /// Gets all properties from shared preferences data set with matching prefix. + func getKeys(allowList: [String]?, options: SharedPreferencesPigeonOptions) throws -> [String] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class UserDefaultsApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `UserDefaultsApi` to handle messages through the `binaryMessenger`. + static func setUp( + binaryMessenger: FlutterBinaryMessenger, api: UserDefaultsApi?, + messageChannelSuffix: String = "" + ) { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Adds property to shared preferences data set of type String. + let setChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.shared_preferences_foundation.UserDefaultsApi.set\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + let valueArg = args[1]! + let optionsArg = args[2] as! SharedPreferencesPigeonOptions + do { + try api.set(key: keyArg, value: valueArg, options: optionsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setChannel.setMessageHandler(nil) + } + /// Removes all properties from shared preferences data set with matching prefix. + let clearChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.UserDefaultsApi.clear\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let allowListArg: [String]? = nilOrValue(args[0]) + let optionsArg = args[1] as! SharedPreferencesPigeonOptions + do { + try api.clear(allowList: allowListArg, options: optionsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + clearChannel.setMessageHandler(nil) + } + /// Gets all properties from shared preferences data set with matching prefix. + let getAllChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.UserDefaultsApi.getAll\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getAllChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let allowListArg: [String]? = nilOrValue(args[0]) + let optionsArg = args[1] as! SharedPreferencesPigeonOptions + do { + let result = try api.getAll(allowList: allowListArg, options: optionsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAllChannel.setMessageHandler(nil) + } + /// Gets individual value stored with [key], if any. + let getValueChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.UserDefaultsApi.getValue\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getValueChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let keyArg = args[0] as! String + let optionsArg = args[1] as! SharedPreferencesPigeonOptions + do { + let result = try api.getValue(key: keyArg, options: optionsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getValueChannel.setMessageHandler(nil) + } + /// Gets all properties from shared preferences data set with matching prefix. + let getKeysChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.shared_preferences_foundation.UserDefaultsApi.getKeys\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getKeysChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let allowListArg: [String]? = nilOrValue(args[0]) + let optionsArg = args[1] as! SharedPreferencesPigeonOptions + do { + let result = try api.getKeys(allowList: allowListArg, options: optionsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getKeysChannel.setMessageHandler(nil) + } + } +} diff --git a/tvos/Runner/Plugins/universal_gamepad/ButtonMapping.swift b/tvos/Runner/Plugins/universal_gamepad/ButtonMapping.swift new file mode 100644 index 00000000..8bee22d6 --- /dev/null +++ b/tvos/Runner/Plugins/universal_gamepad/ButtonMapping.swift @@ -0,0 +1,146 @@ +import GameController + +/// Maps GCController elements to W3C Standard Gamepad indices. +/// +/// W3C Standard Gamepad button mapping: +/// 0 = A (South) 8 = Back/View +/// 1 = B (East) 9 = Start/Menu +/// 2 = X (West) 10 = Left Stick Button +/// 3 = Y (North) 11 = Right Stick Button +/// 4 = Left Shoulder 12 = D-pad Up +/// 5 = Right Shoulder 13 = D-pad Down +/// 6 = Left Trigger 14 = D-pad Left +/// 7 = Right Trigger 15 = D-pad Right +/// 16 = Guide/Home +/// +/// W3C Standard Gamepad axis mapping: +/// 0 = Left Stick X (-1 left, +1 right) +/// 1 = Left Stick Y (-1 up, +1 down) +/// 2 = Right Stick X (-1 left, +1 right) +/// 3 = Right Stick Y (-1 up, +1 down) +/// +/// Note: GCController reports Y-axis with up = +1, which is the opposite of +/// the W3C convention (up = -1). The axis mapping inverts Y values. +enum ButtonMapping { + + /// Returns the W3C button index and the corresponding `GCControllerButtonInput` + /// if `element` matches a known button in `gamepad`. + static func buttonIndex(for element: GCControllerElement, + in gamepad: GCExtendedGamepad) -> (Int, GCControllerButtonInput)? { + // Face buttons + if element == gamepad.buttonA { + return (0, gamepad.buttonA) + } + if element == gamepad.buttonB { + return (1, gamepad.buttonB) + } + if element == gamepad.buttonX { + return (2, gamepad.buttonX) + } + if element == gamepad.buttonY { + return (3, gamepad.buttonY) + } + + // Shoulders + if element == gamepad.leftShoulder { + return (4, gamepad.leftShoulder) + } + if element == gamepad.rightShoulder { + return (5, gamepad.rightShoulder) + } + + // Triggers + if element == gamepad.leftTrigger { + return (6, gamepad.leftTrigger) + } + if element == gamepad.rightTrigger { + return (7, gamepad.rightTrigger) + } + + // Menu buttons + if let buttonOptions = gamepad.buttonOptions, element == buttonOptions { + return (8, buttonOptions) + } + if element == gamepad.buttonMenu { + return (9, gamepad.buttonMenu) + } + + // Thumbstick buttons + if let leftThumbstickButton = gamepad.leftThumbstickButton, element == leftThumbstickButton { + return (10, leftThumbstickButton) + } + if let rightThumbstickButton = gamepad.rightThumbstickButton, element == rightThumbstickButton { + return (11, rightThumbstickButton) + } + + // D-pad individual buttons + if element == gamepad.dpad.up { + return (12, gamepad.dpad.up) + } + if element == gamepad.dpad.down { + return (13, gamepad.dpad.down) + } + if element == gamepad.dpad.left { + return (14, gamepad.dpad.left) + } + if element == gamepad.dpad.right { + return (15, gamepad.dpad.right) + } + + // Guide / Home button + if let buttonHome = gamepad.buttonHome, element == buttonHome { + return (16, buttonHome) + } + + return nil + } + + /// Returns all W3C axis events for the given `element`. + /// + /// The `valueChangedHandler` on `GCExtendedGamepad` may fire with the + /// entire `GCControllerDirectionPad` (thumbstick) as the element, or with + /// an individual axis sub-element. When the full thumbstick fires we + /// return both X and Y axis entries. When a single axis fires we return + /// one entry. + /// + /// Y-axis values are inverted: GCController uses +1 for up, but the W3C + /// standard uses -1 for up. + static func axisIndices(for element: GCControllerElement, + in gamepad: GCExtendedGamepad) -> [(Int, Double)] { + // Left stick (full thumbstick element) + if element == gamepad.leftThumbstick { + return [ + (0, Double(gamepad.leftThumbstick.xAxis.value)), + (1, Double(-gamepad.leftThumbstick.yAxis.value)), + ] + } + + // Left stick individual axes + if element == gamepad.leftThumbstick.xAxis { + return [(0, Double(gamepad.leftThumbstick.xAxis.value))] + } + if element == gamepad.leftThumbstick.yAxis { + // Invert Y: GC up is +1, W3C up is -1 + return [(1, Double(-gamepad.leftThumbstick.yAxis.value))] + } + + // Right stick (full thumbstick element) + if element == gamepad.rightThumbstick { + return [ + (2, Double(gamepad.rightThumbstick.xAxis.value)), + (3, Double(-gamepad.rightThumbstick.yAxis.value)), + ] + } + + // Right stick individual axes + if element == gamepad.rightThumbstick.xAxis { + return [(2, Double(gamepad.rightThumbstick.xAxis.value))] + } + if element == gamepad.rightThumbstick.yAxis { + // Invert Y: GC up is +1, W3C up is -1 + return [(3, Double(-gamepad.rightThumbstick.yAxis.value))] + } + + return [] + } +} diff --git a/tvos/Runner/Plugins/universal_gamepad/GCControllerManager.swift b/tvos/Runner/Plugins/universal_gamepad/GCControllerManager.swift new file mode 100644 index 00000000..eef68820 --- /dev/null +++ b/tvos/Runner/Plugins/universal_gamepad/GCControllerManager.swift @@ -0,0 +1,244 @@ +import GameController + +/// Manages GCController connections and input, translating events into the +/// compact wire-format arrays expected by the Dart side. +class GCControllerManager { + + // MARK: - Properties + + private let streamHandler: GamepadStreamHandler + + /// Maps a GCController instance to its stable int gamepad ID. + private var controllerIds: [ObjectIdentifier: Int] = [:] + + /// Counter for generating unique IDs. + private var nextId: Int = 0 + + /// Notification observers so we can remove them on dispose. + private var connectObserver: NSObjectProtocol? + private var disconnectObserver: NSObjectProtocol? + + // MARK: - Init + + init(streamHandler: GamepadStreamHandler) { + self.streamHandler = streamHandler + } + + // MARK: - Lifecycle + + /// Begin listening for GCController connect/disconnect notifications and + /// register input handlers on any controllers that are already connected. + func startObserving() { + connectObserver = NotificationCenter.default.addObserver( + forName: .GCControllerDidConnect, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + let controller = notification.object as? GCController else { return } + self.controllerDidConnect(controller) + } + + disconnectObserver = NotificationCenter.default.addObserver( + forName: .GCControllerDidDisconnect, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + let controller = notification.object as? GCController else { return } + self.controllerDidDisconnect(controller) + } + + // Handle controllers that were already connected before we started + // observing (e.g. wired controllers). + for controller in GCController.controllers() { + controllerDidConnect(controller) + } + } + + /// Remove all observers and clear input handlers. + func stopObserving() { + if let observer = connectObserver { + NotificationCenter.default.removeObserver(observer) + connectObserver = nil + } + if let observer = disconnectObserver { + NotificationCenter.default.removeObserver(observer) + disconnectObserver = nil + } + + // Remove value changed handlers from all tracked controllers. + for controller in GCController.controllers() { + controller.extendedGamepad?.valueChangedHandler = nil + } + + controllerIds.removeAll() + } + + // MARK: - listGamepads + + /// Returns an array of dictionaries describing currently connected + /// controllers, suitable for returning over a MethodChannel. + func listGamepads() -> [[String: Any]] { + var result: [[String: Any]] = [] + for controller in GCController.controllers() { + let key = ObjectIdentifier(controller) + guard let gamepadId = controllerIds[key] else { continue } + + var info: [String: Any] = [ + "id": gamepadId, + "name": controllerName(controller), + ] + + if let vendorId = vendorId(for: controller) { + info["vendorId"] = vendorId + } + if let productId = productId(for: controller) { + info["productId"] = productId + } + + result.append(info) + } + return result + } + + // MARK: - Connection events + + private func controllerDidConnect(_ controller: GCController) { + let gamepadId = assignId(for: controller) + + // Register input handlers for the extended gamepad profile. + registerInputHandlers(for: controller, gamepadId: gamepadId) + + // Wire format: [0, gamepadId, timestamp, connected, name, vendorId, productId] + let event: [Any] = [ + 0, + gamepadId, + currentTimestamp(), + true, + controllerName(controller), + vendorId(for: controller) as Any, + productId(for: controller) as Any, + ] + streamHandler.send(event: event) + } + + private func controllerDidDisconnect(_ controller: GCController) { + let key = ObjectIdentifier(controller) + guard let gamepadId = controllerIds.removeValue(forKey: key) else { return } + + controller.extendedGamepad?.valueChangedHandler = nil + + // Wire format: [0, gamepadId, timestamp, connected, name, vendorId, productId] + let event: [Any] = [ + 0, + gamepadId, + currentTimestamp(), + false, + controllerName(controller), + vendorId(for: controller) as Any, + productId(for: controller) as Any, + ] + streamHandler.send(event: event) + } + + // MARK: - Input handlers + + private func registerInputHandlers(for controller: GCController, gamepadId: Int) { + guard let extendedGamepad = controller.extendedGamepad else { return } + + extendedGamepad.valueChangedHandler = { [weak self] (gamepad, element) in + guard let self = self else { return } + self.handleValueChanged(gamepad: gamepad, element: element, gamepadId: gamepadId) + } + } + + private func handleValueChanged(gamepad: GCExtendedGamepad, + element: GCControllerElement, + gamepadId: Int) { + let timestamp = currentTimestamp() + + // D-pad fires as a single GCControllerDirectionPad element, not as + // individual direction buttons. Expand it into 4 button events. + if element === gamepad.dpad { + sendButtonEvent(gamepadId: gamepadId, index: 12, + pressed: gamepad.dpad.up.isPressed, + value: Double(gamepad.dpad.up.value), timestamp: timestamp) + sendButtonEvent(gamepadId: gamepadId, index: 13, + pressed: gamepad.dpad.down.isPressed, + value: Double(gamepad.dpad.down.value), timestamp: timestamp) + sendButtonEvent(gamepadId: gamepadId, index: 14, + pressed: gamepad.dpad.left.isPressed, + value: Double(gamepad.dpad.left.value), timestamp: timestamp) + sendButtonEvent(gamepadId: gamepadId, index: 15, + pressed: gamepad.dpad.right.isPressed, + value: Double(gamepad.dpad.right.value), timestamp: timestamp) + return + } + + // Check buttons. + if let (index, button) = ButtonMapping.buttonIndex(for: element, in: gamepad) { + sendButtonEvent(gamepadId: gamepadId, index: index, + pressed: button.isPressed, + value: Double(button.value), timestamp: timestamp) + return + } + + // Check axes (may produce multiple events when a full thumbstick fires). + let axisEvents = ButtonMapping.axisIndices(for: element, in: gamepad) + for (index, axisValue) in axisEvents { + // Wire format: [2, gamepadId, timestamp, axisIndex, value] + streamHandler.send(event: [2, gamepadId, timestamp, index, axisValue]) + } + } + + private func sendButtonEvent(gamepadId: Int, index: Int, + pressed: Bool, value: Double, timestamp: Int) { + // Wire format: [1, gamepadId, timestamp, buttonIndex, pressed, value] + streamHandler.send(event: [1, gamepadId, timestamp, index, pressed, value]) + } + + // MARK: - Helpers + + private func assignId(for controller: GCController) -> Int { + let key = ObjectIdentifier(controller) + if let existing = controllerIds[key] { + return existing + } + let id = nextId + nextId += 1 + controllerIds[key] = id + return id + } + + private func controllerName(_ controller: GCController) -> String { + let category = controller.productCategory + if !category.isEmpty { + return category + } + return controller.vendorName ?? "Unknown Controller" + } + + /// Returns the USB vendor ID for the controller, if available. + /// + /// GCController does not directly expose numeric vendor/product IDs + /// through the public API. We attempt to obtain them from the + /// underlying physical device description on iOS 16+. + private func vendorId(for controller: GCController) -> Int? { + // GCController does not publicly expose numeric vendor IDs on iOS. + // Return nil; the Dart side treats this as optional. + return nil + } + + /// Returns the USB product ID for the controller, if available. + private func productId(for controller: GCController) -> Int? { + // GCController does not publicly expose numeric product IDs on iOS. + // Return nil; the Dart side treats this as optional. + return nil + } + + /// Returns the current timestamp in milliseconds since epoch. + private func currentTimestamp() -> Int { + return Int(Date().timeIntervalSince1970 * 1000) + } +} diff --git a/tvos/Runner/Plugins/universal_gamepad/GamepadPlugin.swift b/tvos/Runner/Plugins/universal_gamepad/GamepadPlugin.swift new file mode 100644 index 00000000..115d879d --- /dev/null +++ b/tvos/Runner/Plugins/universal_gamepad/GamepadPlugin.swift @@ -0,0 +1,61 @@ +import Flutter +import UIKit + +/// Entry point for the gamepad iOS plugin. +/// +/// Registers a MethodChannel (`dev.universal_gamepad/methods`) and an +/// EventChannel (`dev.universal_gamepad/events`) with the Flutter engine. +public class GamepadPlugin: NSObject, FlutterPlugin { + + private let controllerManager: GCControllerManager + private let streamHandler: GamepadStreamHandler + + init(controllerManager: GCControllerManager, streamHandler: GamepadStreamHandler) { + self.controllerManager = controllerManager + self.streamHandler = streamHandler + super.init() + } + + // MARK: - FlutterPlugin + + public static func register(with registrar: FlutterPluginRegistrar) { + let streamHandler = GamepadStreamHandler() + let controllerManager = GCControllerManager(streamHandler: streamHandler) + + let methodChannel = FlutterMethodChannel( + name: "dev.universal_gamepad/methods", + binaryMessenger: registrar.messenger() + ) + + let eventChannel = FlutterEventChannel( + name: "dev.universal_gamepad/events", + binaryMessenger: registrar.messenger() + ) + + let instance = GamepadPlugin( + controllerManager: controllerManager, + streamHandler: streamHandler + ) + + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(streamHandler) + + // Start observing controllers immediately so we capture connections + // that happen before the Dart side listens. + controllerManager.startObserving() + } + + // MARK: - MethodChannel + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "listGamepads": + result(controllerManager.listGamepads()) + case "dispose": + controllerManager.stopObserving() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/tvos/Runner/Plugins/universal_gamepad/GamepadStreamHandler.swift b/tvos/Runner/Plugins/universal_gamepad/GamepadStreamHandler.swift new file mode 100644 index 00000000..60e4b930 --- /dev/null +++ b/tvos/Runner/Plugins/universal_gamepad/GamepadStreamHandler.swift @@ -0,0 +1,62 @@ +import Flutter + +/// FlutterStreamHandler that forwards native gamepad events to Dart. +/// +/// Events are queued if no sink is attached yet; once a listener subscribes +/// all queued events are flushed immediately. +public class GamepadStreamHandler: NSObject, FlutterStreamHandler { + + /// The active event sink provided by Flutter's EventChannel. + private var eventSink: FlutterEventSink? + + /// Events received before the Dart side starts listening. + private var pendingEvents: [[Any]] = [] + + /// Thread-safety lock for sink / queue access. + private let lock = NSLock() + + // MARK: - FlutterStreamHandler + + public func onListen(withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? { + lock.lock() + eventSink = events + + // Flush any events that arrived before the listener was attached. + for event in pendingEvents { + events(event) + } + pendingEvents.removeAll() + lock.unlock() + + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + lock.lock() + eventSink = nil + lock.unlock() + + return nil + } + + // MARK: - Internal API + + /// Sends a gamepad event array to Dart. + /// + /// If the sink is not yet available the event is queued. + func send(event: [Any]) { + lock.lock() + if let sink = eventSink { + lock.unlock() + // Dispatch on the main thread to satisfy Flutter platform channel + // requirements. + DispatchQueue.main.async { + sink(event) + } + } else { + pendingEvents.append(event) + lock.unlock() + } + } +} diff --git a/tvos/Runner/Runner-Bridging-Header.h b/tvos/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/tvos/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/tvos/engine.version b/tvos/engine.version new file mode 100644 index 00000000..b966787a --- /dev/null +++ b/tvos/engine.version @@ -0,0 +1 @@ +3.41.6 diff --git a/tvos/scripts/Info.plist b/tvos/scripts/Info.plist new file mode 100644 index 00000000..d57061dd --- /dev/null +++ b/tvos/scripts/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/tvos/scripts/copy_assets.sh b/tvos/scripts/copy_assets.sh new file mode 100644 index 00000000..fa9f87d3 --- /dev/null +++ b/tvos/scripts/copy_assets.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + + +# Exit on error +set -e + + +#determine ios folder +if [[ -d ./_ios ]]; then + IOS_DIR=./_ios + + if [[ -d ./ios ]]; then + TVOS_DIR=./ios + else + echo "Error: could not find './ios' folder with tvos content" + exit 1 + fi +elif [[ -d ./ios ]]; then + IOS_DIR=./ios + + if [[ -d ./tvos ]]; then + TVOS_DIR=./tvos + else + echo "Error: could not find './tvos' folder with tvos content" + exit 1 + fi +else + echo "Error: could not find './ios' or './_ios' folder" + exit 1 +fi + + +IOS_FLUTTER_ASSETS_DIR=$IOS_DIR/Flutter/App.framework/flutter_assets +TVOS_FLUTTER_ASSETS_DIR=$TVOS_DIR/tvos_flutter_assets/flutter_assets + + +if [[ -d $IOS_FLUTTER_ASSETS_DIR ]]; then + + echo "Copying flutter_assets from IOS compiled target" + + + # better approuch would to copy complete assets folder and remove files that are generated by compilation step + # isolate_snapshot_data, isolate_snapshot_instr, kernel_blob, vm_snapshot_data + # note, this list might be different for debug (jit) and release (aot)?????? + + rm -rf "$TVOS_FLUTTER_ASSETS_DIR" + mkdir -p "$TVOS_FLUTTER_ASSETS_DIR" + + echo " └─App.framework/flutter_assets/AssetManifest.json" + cp -v "$IOS_FLUTTER_ASSETS_DIR/AssetManifest.json" "$TVOS_FLUTTER_ASSETS_DIR/AssetManifest.json" + + echo " └─App.framework/flutter_assets/FontManifest.json" + cp -v "$IOS_FLUTTER_ASSETS_DIR/FontManifest.json" "$TVOS_FLUTTER_ASSETS_DIR/FontManifest.json" + +set +e + # the content has changed from flutter v 1.15.19 to 1.19.0-4.3pre + echo " └─App.framework/flutter_assets/LICENSE" + cp -v "$IOS_FLUTTER_ASSETS_DIR/LICENSE" "$TVOS_FLUTTER_ASSETS_DIR/LICENSE" + + echo " └─App.framework/flutter_assets/NOTICES" + cp -v "$IOS_FLUTTER_ASSETS_DIR/NOTICES" "$TVOS_FLUTTER_ASSETS_DIR/NOTICES" + + echo " └─App.framework/flutter_assets/assets/" + mkdir -p "$TVOS_FLUTTER_ASSETS_DIR/assets" + cp -v -R "$IOS_FLUTTER_ASSETS_DIR/assets" "$TVOS_FLUTTER_ASSETS_DIR" + + echo " └─App.framework/flutter_assets/fonts/" + mkdir -p "$TVOS_FLUTTER_ASSETS_DIR/fonts" + cp -v -R "$IOS_FLUTTER_ASSETS_DIR/fonts" "$TVOS_FLUTTER_ASSETS_DIR" + + echo " └─App.framework/flutter_assets/packages/" + mkdir -p "$TVOS_FLUTTER_ASSETS_DIR/packages" + cp -v -R "$IOS_FLUTTER_ASSETS_DIR/packages" "$TVOS_FLUTTER_ASSETS_DIR" + +set -e + echo " └─Done" + +else + echo "'./ios/Flutter/App.framework/flutter_assets' folder does not not exist or IOS target not compiled yet, build any ios buiild first!" + exit 1 +fi \ No newline at end of file diff --git a/tvos/scripts/copy_framework.sh b/tvos/scripts/copy_framework.sh new file mode 100755 index 00000000..2d084dbc --- /dev/null +++ b/tvos/scripts/copy_framework.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# based on scripts in /engine/src/flutter/testing/scenario_app +# and xcode_backend.sh script for integration in xcode + + +# Exit on error +set -e + +if [[ $(uname -m) == "arm64" ]]; then + TARGET_POSTFIX='_arm64' + CLANG_POSTFIX='_arm64' +else + TARGET_POSTFIX='' + CLANG_POSTFIX='_X86' +fi + + +if [ -z "$FLUTTER_LOCAL_ENGINE" ]; then + echo " └─ERROR: FLUTTER_LOCAL_ENGINE not set!" + return 1; +fi + +if [ "$1" == "release" ]; then + echo "Coping Flutter.framework (release)..." + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/ios_release$TARGET_POSTFIX +elif [[ "$1" == "debug_sim" ]] ; then + echo "Coping Flutter.framework (debug-simulator)..." + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/ios_debug_sim_unopt$TARGET_POSTFIX +else + #debug + echo "Coping Flutter.framework (debug)..." + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/ios_debug_unopt$TARGET_POSTFIX +fi + + +OUTDIR=$PWD/Flutter + +rm -rf "$OUTDIR/Flutter.framework" +cp -R "$DEVICE_TOOLS/Flutter.framework" "$OUTDIR" + + diff --git a/tvos/scripts/fetch_engine.sh b/tvos/scripts/fetch_engine.sh new file mode 100755 index 00000000..a981a18b --- /dev/null +++ b/tvos/scripts/fetch_engine.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Download the prebuilt flutter-tvos engine tarball from GitHub Releases, +# extract it into a shared cache, and write tvos/Flutter/Generated.xcconfig +# so Xcode picks it up via FLUTTER_LOCAL_ENGINE. +# +# Reads the engine version from tvos/engine.version. Re-runs are cheap — +# skips download if the cache already has the matching version. +# +# Usage: +# tvos/scripts/fetch_engine.sh +# +# Env overrides: +# FLUTTER_TVOS_ENGINE_CACHE — root dir for cached engines (default: ~/.cache/flutter-tvos-engine) +# FLUTTER_TVOS_RELEASES_URL — base URL for release tarballs (default: github.com/edde746/flutter-tvos) + +set -euo pipefail + +TVOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${TVOS_DIR}/.." && pwd)" + +VERSION_FILE="${TVOS_DIR}/engine.version" +if [[ ! -f "$VERSION_FILE" ]]; then + echo "error: $VERSION_FILE missing" >&2 + exit 1 +fi +VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")" +if [[ -z "$VERSION" ]]; then + echo "error: tvos/engine.version is empty" >&2 + exit 1 +fi + +CACHE_ROOT="${FLUTTER_TVOS_ENGINE_CACHE:-$HOME/.cache/flutter-tvos-engine}" +RELEASES_URL="${FLUTTER_TVOS_RELEASES_URL:-https://github.com/edde746/flutter-tvos}" + +ENGINE_DIR="${CACHE_ROOT}/v${VERSION}" +TARBALL_URL="${RELEASES_URL}/releases/download/v${VERSION}/flutter-tvos-${VERSION}.tar.gz" +STAMP="${ENGINE_DIR}/.installed-${VERSION}" + +if [[ ! -f "$STAMP" ]]; then + echo "[fetch_engine] downloading ${TARBALL_URL}" + mkdir -p "$ENGINE_DIR" + TMP_TAR="$(mktemp -t flutter-tvos-engine.XXXXXX.tar.gz)" + trap 'rm -f "$TMP_TAR"' EXIT + curl -fL --progress-bar -o "$TMP_TAR" "$TARBALL_URL" + echo "[fetch_engine] extracting to $ENGINE_DIR" + tar -xzf "$TMP_TAR" -C "$ENGINE_DIR" + touch "$STAMP" + rm -f "$TMP_TAR" + trap - EXIT +else + echo "[fetch_engine] using cached engine at $ENGINE_DIR" +fi + +# Locate a host Flutter SDK for flutter CLI invocation during the build. +if [[ -n "${FLUTTER_ROOT:-}" ]]; then + FLUTTER_ROOT_RESOLVED="$FLUTTER_ROOT" +elif command -v flutter >/dev/null 2>&1; then + FLUTTER_ROOT_RESOLVED="$(dirname "$(dirname "$(command -v flutter)")")" +else + echo "error: couldn't find Flutter SDK — set FLUTTER_ROOT or put flutter on PATH" >&2 + exit 1 +fi + +# Read version name/number from pubspec so Generated.xcconfig matches the app. +PUBSPEC="${REPO_ROOT}/pubspec.yaml" +PUB_NAME="" +PUB_NUMBER="" +if [[ -f "$PUBSPEC" ]]; then + VER_LINE="$(awk '/^version:/ {print $2; exit}' "$PUBSPEC" | tr -d '"')" + if [[ -n "$VER_LINE" ]]; then + PUB_NAME="${VER_LINE%+*}" + if [[ "$VER_LINE" == *+* ]]; then + PUB_NUMBER="${VER_LINE#*+}" + fi + fi +fi +PUB_NAME="${PUB_NAME:-1.0.0}" +PUB_NUMBER="${PUB_NUMBER:-1}" + +# tvOS deployment target is hard-coded at the Podfile / project level to 14.0. +# Write Generated.xcconfig so Xcode (and the Run Script phase) see the engine. +GEN_XC="${TVOS_DIR}/Flutter/Generated.xcconfig" +mkdir -p "$(dirname "$GEN_XC")" +cat > "$GEN_XC" < should work with any version! + +cd ../.. + + diff --git a/tvos/scripts/set_tvos_target_runner.sh b/tvos/scripts/set_tvos_target_runner.sh new file mode 100644 index 00000000..1531f90e --- /dev/null +++ b/tvos/scripts/set_tvos_target_runner.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + + +# Note: sed tool only works when going in to the actual folder of the package, specifying the direct full path to the project.pbxproj does not work! + +cd Runner.xcodeproj +echo "pathching $PWD/project.pbxproj ..." +find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/TARGETED_DEVICE_FAMILY[[:space:]]=[[:space:]]\"1,2\"/TARGETED_DEVICE_FAMILY = \"3\"/g' +find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SDKROOT[[:space:]]=[[:space:]]iphoneos/SDKROOT = appletvos/g' +find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SUPPORTED_PLATFORMS[[:space:]]=[[:space:]]iphoneos/SUPPORTED_PLATFORMS = appletvos/g' +find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/IPHONEOS_DEPLOYMENT_TARGET[[:space:]]=[[:space:]][0-9].[0-9]/TVOS_DEPLOYMENT_TARGET = 13.0/g' +# TODO: 8.0 --> should work with any version! + +cd .. + + diff --git a/tvos/scripts/switch_target.sh b/tvos/scripts/switch_target.sh new file mode 100755 index 00000000..c3282b06 --- /dev/null +++ b/tvos/scripts/switch_target.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -e + +if [ "$1" == "ios" ]; then + if [[ ! -d ./tvos ]] && [[ -d ./_ios ]] && [[ -d ./ios ]]; then + echo "Renaming 'ios' to 'tvos'" + mv ./ios ./tvos + mv ./_ios ./ios + else + echo "'tvos' already exists or '_ios'/'ios' does not exist" + exit 1 + fi + +elif [[ "$1" == "tvos" ]] ; then + if [[ ! -d ./_ios ]] && [[ -d ./tvos ]] && [[ -d ./ios ]]; then + echo "Renaming 'tvos' to 'ios'" + mv ./ios ./_ios + mv ./tvos ./ios + else + echo "'_ios' already exists or 'tvos'/'ios' does not exist" + exit 1 + fi + +else + echo "invalid arguments:" + echo " Usage: $0 ios or $0 tvos" + exit 1 +fi \ No newline at end of file diff --git a/tvos/scripts/wire_mpv.rb b/tvos/scripts/wire_mpv.rb new file mode 100755 index 00000000..d5f1eba3 --- /dev/null +++ b/tvos/scripts/wire_mpv.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# Adds the Plezy MpvPlayer Swift sources and the MPVKit Swift Package +# dependency to tvos/Runner.xcodeproj so it matches the iOS project's +# linkage. Idempotent: re-running skips already-added entries. + +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../Runner.xcodeproj', __dir__) +project = Xcodeproj::Project.open(PROJECT_PATH) +runner_target = project.targets.find { |t| t.name == 'Runner' } +raise "Runner target not found" unless runner_target + +# Find or create the MpvPlayer group under Runner. +main_group = project.main_group['Runner'] +raise "Runner group not found" unless main_group +mpv_group = main_group['MpvPlayer'] || main_group.new_group('MpvPlayer', 'Runner/MpvPlayer') + +# File references. +# path is relative to the group's path (Runner/MpvPlayer → ../..). +# For files elsewhere in the repo, use SOURCE_ROOT with the absolute-ish path. +sources = [ + { name: 'MpvPlayerCoreBase.swift', path: '../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift', tree: '' }, + { name: 'MpvPlayerPluginShared.swift', path: '../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift', tree: '' }, + { name: 'MpvPlayerCore.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerCore.swift', tree: '' }, + { name: 'MpvPlayerPlugin.swift', path: '../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift', tree: '' }, + { name: 'MpvPipController.swift', path: '../ios/Runner/MpvPlayer/MpvPipController.swift', tree: '' }, +] + +sources_phase = runner_target.source_build_phase +sources.each do |src| + existing = mpv_group.files.find { |f| f.display_name == src[:name] } + if existing + puts "[skip] #{src[:name]} already present" + next + end + ref = mpv_group.new_file(src[:path]) + ref.name = src[:name] + ref.source_tree = src[:tree] + sources_phase.add_file_reference(ref, true) + puts "[add ] #{src[:name]}" +end + +# Swift Package: MPVKit. +pkg_url = 'https://github.com/edde746/MPVKit' +existing_pkg = project.root_object.package_references.find do |p| + p.repositoryURL == pkg_url rescue false +end + +if existing_pkg + puts "[skip] MPVKit SPM package already present" +else + pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) + pkg.repositoryURL = pkg_url + pkg.requirement = { 'kind' => 'branch', 'branch' => 'main' } + project.root_object.package_references << pkg + + product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + product.package = pkg + product.product_name = 'MPVKit' + runner_target.package_product_dependencies << product + + frameworks_phase = runner_target.frameworks_build_phase + build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile) + build_file.product_ref = product + frameworks_phase.files << build_file + puts "[add ] MPVKit SPM package + framework linkage" +end + +project.save +puts "Saved #{PROJECT_PATH}" diff --git a/tvos/scripts/wire_plugins.rb b/tvos/scripts/wire_plugins.rb new file mode 100755 index 00000000..50d614c0 --- /dev/null +++ b/tvos/scripts/wire_plugins.rb @@ -0,0 +1,93 @@ +#!/usr/bin/env ruby +# Adds pure-Swift Flutter plugin sources to Runner target (no pods needed). +# Cleans up stale plugin file references left from earlier failed attempts. + +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 + +# Names we want to own (anything with these basenames that's NOT part of +# MpvPlayer is a stale duplicate from earlier failed runs). +PLUGIN_BASENAMES = %w[ + SharedPreferencesPlugin.swift + messages.g.swift + PackageInfoPlusPlugin.swift + PathProviderPlugin.swift + GamepadPlugin.swift + GamepadStreamHandler.swift + GCControllerManager.swift + ButtonMapping.swift + DeviceInfoPlusPlugin.swift + ConnectivityPlusPlugin.swift + ConnectivityProvider.swift + PathMonitorConnectivityProvider.swift + OsMediaControlsPlugin.swift +] + +# Remove any existing file refs for these basenames from the entire project. +project.files.select { |f| PLUGIN_BASENAMES.include?(f.display_name) }.each do |f| + f.remove_from_project +end + +# Drop any build files whose file_ref is now nil. +project.targets.each do |t| + t.build_phases.each do |phase| + next unless phase.respond_to?(:files) + phase.files.delete_if { |bf| bf.file_ref.nil? } + end +end + +# Remove any empty Plugins groups. +runner_group = project.main_group['Runner'] +if (prev = runner_group['Plugins']) + prev.remove_from_project +end + +# Fresh Plugins group, filesystem-aligned under tvos/Runner/Plugins. +plugins_group = runner_group.new_group('Plugins', 'Plugins') + +plugins = { + 'shared_preferences_foundation' => %w[ + SharedPreferencesPlugin.swift + messages.g.swift + ], + 'package_info_plus' => %w[ + PackageInfoPlusPlugin.swift + ], + 'path_provider' => %w[ + PathProviderPlugin.swift + ], + 'universal_gamepad' => %w[ + GamepadPlugin.swift + GamepadStreamHandler.swift + GCControllerManager.swift + ButtonMapping.swift + ], + 'device_info_plus' => %w[ + DeviceInfoPlusPlugin.swift + ], + 'connectivity_plus' => %w[ + ConnectivityPlusPlugin.swift + ConnectivityProvider.swift + PathMonitorConnectivityProvider.swift + ], + 'os_media_controls' => %w[ + OsMediaControlsPlugin.swift + ], +} + +sources_phase = runner.source_build_phase +plugins.each do |plugin_name, files| + sub = plugins_group.new_group(plugin_name, plugin_name) + files.each do |fname| + ref = sub.new_file(fname) + sources_phase.add_file_reference(ref, true) + puts "[add ] Runner/Plugins/#{plugin_name}/#{fname}" + end +end + +project.save +puts "Saved" diff --git a/tvos/scripts/xcode_appletv.sh b/tvos/scripts/xcode_appletv.sh new file mode 100755 index 00000000..9a0b74b4 --- /dev/null +++ b/tvos/scripts/xcode_appletv.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash + +# based on scripts in /engine/src/flutter/testing/scenario_app +# and xcode_backend.sh script for integration in xcode + + +# Exit on error +set -e + +debug_sim="" + + +if [[ $(uname -m) == "arm64" ]]; then + TARGET_POSTFIX='_arm64' + CLANG_POSTFIX='_arm64' + SIM_ARCH='arm64' +else + TARGET_POSTFIX='' + CLANG_POSTFIX='_X86' + SIM_ARCH='x86_64' +fi + +BuildAppDebug() { + # Host tools (frontend_server, patched SDK, dartaotruntime) ship in + # host_release for both debug and release consumers — the frontend_server + # compiles debug kernels regardless of the host build flavor. + HOST_TOOLS=$FLUTTER_LOCAL_ENGINE/out/host_release + if [[ "$debug_sim" == "true" ]]; then + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/tvos_debug_sim_unopt$TARGET_POSTFIX + else + # Device build is always arm64; gn outputs `tvos_debug_unopt` without suffix. + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/tvos_debug_unopt + fi + + ROOTDIR=$(dirname "$PROJECT_DIR") + OUTDIR=$ROOTDIR/build/ios/Release-iphoneos + mkdir -p $OUTDIR + echo " └─OUTDIR: $OUTDIR" + echo " └─BUILT_PRODUCTS_DIR: $BUILT_PRODUCTS_DIR" + + + echo " └─Copying Flutter.framework" + rm -rf "$OUTDIR/Flutter.framework" + cp -R "$DEVICE_TOOLS/Flutter.framework" "$OUTDIR" + + + tvos_deployment_target="$TVOS_DEPLOYMENT_TARGET" + + echo " └─Generate flutter_assets via flutter build bundle" + mkdir -p "$OUTDIR/App.framework/flutter_assets" + # Resolve the flutter CLI: prefer FLUTTER_ROOT from Generated.xcconfig, else + # fall back to the `flutter` on PATH. + FLUTTER_BIN="" + if [ -n "$FLUTTER_ROOT" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then + FLUTTER_BIN="$FLUTTER_ROOT/bin/flutter" + elif command -v flutter >/dev/null 2>&1; then + FLUTTER_BIN="$(command -v flutter)" + fi + + if [ -z "$FLUTTER_BIN" ]; then + echo " └─ERROR: flutter CLI not found (set FLUTTER_ROOT or add flutter to PATH)" + return 1 + fi + + # flutter build bundle produces: AssetManifest, FontManifest, NOTICES, + # shaders, fonts, assets, packages, plus a kernel_blob.bin and + # isolate_snapshot_data compiled against the stock flutter engine. We + # overwrite kernel_blob.bin and both snapshot blobs below with versions + # from our tvOS engine so the tvOS VM can load them. + ( + cd "$FLUTTER_APPLICATION_PATH" && \ + "$FLUTTER_BIN" build bundle \ + --asset-dir="$OUTDIR/App.framework/flutter_assets" \ + --no-tree-shake-icons \ + --suppress-analytics + ) || { + echo " └─ERROR: flutter build bundle failed" + return 1 + } + + echo " └─Compiling tvOS kernel via local engine frontend_server" + FRONTEND_SERVER="$HOST_TOOLS/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot" + if [ ! -f "$FRONTEND_SERVER" ]; then + FRONTEND_SERVER="$HOST_TOOLS/gen/frontend_server_aot.dart.snapshot" + fi + "$HOST_TOOLS/dart-sdk/bin/dartaotruntime" \ + "$FRONTEND_SERVER" \ + --sdk-root "$HOST_TOOLS/flutter_patched_sdk" \ + --tfa --target=flutter \ + -DTVOS_BUILD=true \ + --output-dill "$OUTDIR/App.framework/flutter_assets/kernel_blob.bin" \ + "$FLUTTER_APPLICATION_PATH/lib/main.dart" + + echo " └─Copying tvOS VM + isolate snapshots (pure JIT, no gen_snapshot)" + cp "$DEVICE_TOOLS/gen/flutter/lib/snapshot/vm_isolate_snapshot.bin" \ + "$OUTDIR/App.framework/flutter_assets/vm_snapshot_data" + cp "$DEVICE_TOOLS/gen/flutter/lib/snapshot/isolate_snapshot.bin" \ + "$OUTDIR/App.framework/flutter_assets/isolate_snapshot_data" + + + if [[ "$debug_sim" == "true" ]]; then + SYSROOT=$(xcrun --sdk appletvsimulator --show-sdk-path) + else + SYSROOT=$(xcrun --sdk appletvos --show-sdk-path) + fi + + echo " └─Creating stub App using $SYSROOT" + + + if [[ "$debug_sim" == "true" ]]; then + echo "static const int Moo = 88;" | xcrun clang -x c \ + -arch $SIM_ARCH \ + -L"$SYSROOT/usr/lib" \ + -isysroot "$SYSROOT" \ + -mappletvsimulator-version-min=$tvos_deployment_target \ + -dynamiclib \ + -Xlinker -rpath -Xlinker '@executable_path/Frameworks' \ + -Xlinker -rpath -Xlinker '@loader_path/Frameworks' \ + -install_name '@rpath/App.framework/App' \ + -o "$OUTDIR/App.framework/App" - + + else + echo "static const int Moo = 88;" | xcrun clang -x c \ + -arch arm64 \ + -isysroot "$SYSROOT" \ + -mtvos-version-min=$tvos_deployment_target \ + -dynamiclib \ + -Xlinker -rpath -Xlinker '@executable_path/Frameworks' \ + -Xlinker -rpath -Xlinker '@loader_path/Frameworks' \ + -install_name '@rpath/App.framework/App' \ + -o "$OUTDIR/App.framework/App" - + fi + + strip "$OUTDIR/App.framework/App" + + echo " └─copy frameworks" + cp "$PROJECT_DIR/scripts/Info.plist" "$OUTDIR/App.framework/Info.plist" + + # For Archive builds BUILT_PRODUCTS_DIR differs from TARGET_BUILD_DIR: + # Swift compile looks up frameworks under BUILT_PRODUCTS_DIR, but the + # embedded copy needs to end up inside TARGET_BUILD_DIR/Runner.app. + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$TARGET_BUILD_DIR" + if [ -n "${BUILT_PRODUCTS_DIR:-}" ] && [ "$BUILT_PRODUCTS_DIR" != "$TARGET_BUILD_DIR" ]; then + rm -rf "$BUILT_PRODUCTS_DIR/App.framework" "$BUILT_PRODUCTS_DIR/Flutter.framework" + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$BUILT_PRODUCTS_DIR" + fi + + # Also embed into Runner.app/Frameworks so the dylib @rpath resolves at launch. + APP_FRAMEWORKS_DIR="$TARGET_BUILD_DIR/$WRAPPER_NAME/Frameworks" + mkdir -p "$APP_FRAMEWORKS_DIR" + rm -rf "$APP_FRAMEWORKS_DIR/App.framework" "$APP_FRAMEWORKS_DIR/Flutter.framework" + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$APP_FRAMEWORKS_DIR" + + # Sign the binaries we moved. Both the flat copy (used by linker/embedder) + # and the embedded copy inside Runner.app/Frameworks need signatures. + # Skip when signing is disabled (CODE_SIGNING_ALLOWED=NO, unit-test builds, + # or sim builds where Xcode doesn't sign frameworks). Xcode's own CodeSign + # phase will sign the final app + frameworks in proper archive builds. + echo " └─Sign" + if [[ "$debug_sim" != "true" && -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" && "${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]]; then + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${TARGET_BUILD_DIR}/App.framework/App" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${TARGET_BUILD_DIR}/Flutter.framework/Flutter" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${APP_FRAMEWORKS_DIR}/App.framework/App" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${APP_FRAMEWORKS_DIR}/Flutter.framework/Flutter" + else + echo " (skipped — no code sign identity or signing disabled)" + fi + + echo " └─Done" + + return 0 +} + + +BuildAppRelease() { + HOST_TOOLS=$FLUTTER_LOCAL_ENGINE/out/host_release + DEVICE_TOOLS=$FLUTTER_LOCAL_ENGINE/out/tvos_release + + # Locate gen_snapshot. Use the cross-compile variant that targets iOS/tvOS + # arm64 (emits arm64-ios assembly). The plain host gen_snapshot targets + # macOS and its snapshots are rejected at runtime by the iOS/tvOS VM. + GEN_SNAPSHOT="" + for cand in \ + "$DEVICE_TOOLS/artifacts_arm64/gen_snapshot_arm64" \ + "$DEVICE_TOOLS/universal/gen_snapshot_arm64" \ + "$DEVICE_TOOLS/artifacts_x64/gen_snapshot_arm64" \ + "$DEVICE_TOOLS/gen_snapshot_arm64"; do + if [ -x "$cand" ]; then + GEN_SNAPSHOT="$cand" + break + fi + done + if [ -z "$GEN_SNAPSHOT" ]; then + echo " └─ERROR: gen_snapshot not found under $DEVICE_TOOLS or $HOST_TOOLS" + return 1 + fi + + ROOTDIR=$(dirname "$PROJECT_DIR") + OUTDIR=$ROOTDIR/build/ios/Release-iphoneos + mkdir -p "$OUTDIR" + + echo " └─OUTDIR: $OUTDIR" + echo " └─gen_snapshot: $GEN_SNAPSHOT" + + echo " └─Copying Flutter.framework" + rm -rf "$OUTDIR/Flutter.framework" + cp -R "$DEVICE_TOOLS/Flutter.framework" "$OUTDIR" + + tvos_deployment_target="$TVOS_DEPLOYMENT_TARGET" + + # Resolve flutter CLI. + FLUTTER_BIN="" + if [ -n "$FLUTTER_ROOT" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then + FLUTTER_BIN="$FLUTTER_ROOT/bin/flutter" + elif command -v flutter >/dev/null 2>&1; then + FLUTTER_BIN="$(command -v flutter)" + fi + if [ -z "$FLUTTER_BIN" ]; then + echo " └─ERROR: flutter CLI not found (set FLUTTER_ROOT or add flutter to PATH)" + return 1 + fi + + echo " └─Generate flutter_assets via flutter build bundle (release)" + mkdir -p "$OUTDIR/App.framework/flutter_assets" + ( + cd "$FLUTTER_APPLICATION_PATH" && \ + "$FLUTTER_BIN" build bundle \ + --release \ + --asset-dir="$OUTDIR/App.framework/flutter_assets" \ + --no-tree-shake-icons \ + --suppress-analytics + ) || { + echo " └─ERROR: flutter build bundle failed" + return 1 + } + # AOT builds don't need kernel_blob.bin in flutter_assets — the compiled + # arm64 code lives in App.framework/App itself. + rm -f "$OUTDIR/App.framework/flutter_assets/kernel_blob.bin" + + echo " └─Compiling AOT kernel via local engine frontend_server" + # The snapshot under dart-sdk/bin/snapshots/ is the actual AOT-compiled one; + # the one under gen/ is a stale/placeholder kernel. + FRONTEND_SERVER="$HOST_TOOLS/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot" + if [ ! -f "$FRONTEND_SERVER" ]; then + FRONTEND_SERVER="$HOST_TOOLS/gen/frontend_server_aot.dart.snapshot" + fi + "$HOST_TOOLS/dart-sdk/bin/dartaotruntime" \ + "$FRONTEND_SERVER" \ + --sdk-root "$HOST_TOOLS/flutter_patched_sdk" \ + --aot --tfa --target=flutter \ + -Ddart.vm.product=true \ + -Ddart.vm.profile=false \ + -DFLUTTER_BUILD_MODE=release \ + -DTARGET_PLATFORM=TVOS \ + -DTVOS_BUILD=true \ + --output-dill "$OUTDIR/app.dill" \ + "$FLUTTER_APPLICATION_PATH/lib/main.dart" + + echo " └─Compiling AOT Assembly" + "$GEN_SNAPSHOT" \ + --deterministic \ + --snapshot_kind=app-aot-assembly \ + --assembly="$OUTDIR/snapshot_assembly.S" \ + --strip \ + "$OUTDIR/app.dill" + + echo " └─Compiling Assembly" + SYSROOT=$(xcrun --sdk appletvos --show-sdk-path) + cc -arch arm64 \ + -isysroot "$SYSROOT" \ + -mtvos-version-min=$tvos_deployment_target \ + -c "$OUTDIR/snapshot_assembly.S" \ + -o "$OUTDIR/snapshot_assembly.o" + + echo " └─Linking app" + clang -arch arm64 \ + -isysroot "$SYSROOT" \ + -mtvos-version-min=$tvos_deployment_target \ + -dynamiclib \ + -Xlinker -rpath -Xlinker @executable_path/Frameworks \ + -Xlinker -rpath -Xlinker @loader_path/Frameworks \ + -install_name @rpath/App.framework/App \ + -o "$OUTDIR/App.framework/App" \ + "$OUTDIR/snapshot_assembly.o" + + strip "$OUTDIR/App.framework/App" + + cp "$PROJECT_DIR/scripts/Info.plist" "$OUTDIR/App.framework/Info.plist" + + echo " └─copy frameworks" + # Archive builds point BUILT_PRODUCTS_DIR at a separate products path that + # Swift compile scans for frameworks — copy there too so downstream + # compilation in the same target can resolve Flutter.h. + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$TARGET_BUILD_DIR" + if [ -n "${BUILT_PRODUCTS_DIR:-}" ] && [ "$BUILT_PRODUCTS_DIR" != "$TARGET_BUILD_DIR" ]; then + rm -rf "$BUILT_PRODUCTS_DIR/App.framework" "$BUILT_PRODUCTS_DIR/Flutter.framework" + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$BUILT_PRODUCTS_DIR" + fi + + APP_FRAMEWORKS_DIR="$TARGET_BUILD_DIR/$WRAPPER_NAME/Frameworks" + mkdir -p "$APP_FRAMEWORKS_DIR" + rm -rf "$APP_FRAMEWORKS_DIR/App.framework" "$APP_FRAMEWORKS_DIR/Flutter.framework" + cp -R "${OUTDIR}/"{App.framework,Flutter.framework} "$APP_FRAMEWORKS_DIR" + + echo " └─Sign" + if [[ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" && "${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]]; then + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${TARGET_BUILD_DIR}/App.framework/App" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${TARGET_BUILD_DIR}/Flutter.framework/Flutter" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${APP_FRAMEWORKS_DIR}/App.framework/App" + codesign --force --verbose --sign "${EXPANDED_CODE_SIGN_IDENTITY}" -- "${APP_FRAMEWORKS_DIR}/Flutter.framework/Flutter" + else + echo " (skipped — no code sign identity or signing disabled)" + fi + + echo " └─Done" + + return 0 +} + + +BuildApp() { + + local build_mode="$(echo "${FLUTTER_BUILD_MODE:-${CONFIGURATION}}" | tr "[:upper:]" "[:lower:]")" + + echo "Compiling Flutter/App.Framework" + + if [ -z "$FLUTTER_LOCAL_ENGINE" ]; then + echo " └─ERROR: FLUTTER_LOCAL_ENGINE not set!" + return 1; + fi + + echo " └─engine $FLUTTER_LOCAL_ENGINE" + + + if [[ "$PLATFORM_NAME" == "appletvsimulator" && "$build_mode" =~ "debug" ]]; then + debug_sim="true" + BuildAppDebug + elif [[ "$build_mode" =~ "debug" ]]; then + BuildAppDebug + elif [[ "$build_mode" =~ "release" ]]; then + # release/archive (archive: build mode == "release" && ${ACTION} == "install") + BuildAppRelease + else + echo " └─ERROR: unknown target: ${build_mode}" + return 1; + fi + + return 0 +} + + +# Main entry point. +if [[ $# == 0 ]]; then + # Backwards-compatibility: if no args are provided, build and embed. + BuildApp + EmbedFlutterFrameworks +else + case $1 in + "build") + BuildApp ;; +# "embed_and_thin") +# "Not needed, used from flutter xcode_backend.sh script" + esac +fi