diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index f2872cf..8c6e561 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index 1e8c3c9..279576f 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9214757..a653c95 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -13,8 +13,9 @@ PODS: - CocoaAsyncSocket - KTVHTTPCache (2.0.1): - KTVCocoaHTTPServer - - path_provider_ios (0.0.1): + - path_provider_foundation (0.0.1): - Flutter + - FlutterMacOS - PINCache (3.0.3): - PINCache/Arc-exception-safe (= 3.0.3) - PINCache/Core (= 3.0.3) @@ -29,7 +30,7 @@ PODS: DEPENDENCIES: - better_player (from `.symlinks/plugins/better_player/ios`) - Flutter (from `Flutter`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - wakelock (from `.symlinks/plugins/wakelock/ios`) SPEC REPOS: @@ -46,23 +47,23 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/better_player/ios" Flutter: :path: Flutter - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" wakelock: :path: ".symlinks/plugins/wakelock/ios" SPEC CHECKSUMS: - better_player: f10a109209a2738647404fe1865d76cb9027d567 + better_player: f52c69d950ca39e57e1ac54be7058ebd342a0bc6 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 KTVCocoaHTTPServer: df8d7b861e603ff8037e9b2138aca2563a6b768d KTVHTTPCache: 588c3eb16f6bd1e6fde1e230dabfb7bd4e490a4d - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 - wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f + wakelock: cc82bb76a8260a57c002d11c0a508b434618379f -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c +PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 -COCOAPODS: 1.11.3 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index c8cb374..6f85988 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -155,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -199,10 +199,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -230,6 +232,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -339,7 +342,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -362,7 +365,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -426,7 +429,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -475,7 +478,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -493,14 +496,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = N7JZ68X9C5; + DEVELOPMENT_TEAM = XUMY2D8NX7; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -532,7 +535,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6..e67b280 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ UIViewControllerBasedStatusBarAppearance + UIApplicationSupportsIndirectInputEvents + diff --git a/example/lib/better_player.dart b/example/lib/better_player.dart index 4056cdb..2f09d13 100644 --- a/example/lib/better_player.dart +++ b/example/lib/better_player.dart @@ -7,7 +7,7 @@ class TheBetterPlayer extends StatefulWidget { } class _BetterPlayerState extends State { - BetterPlayerController ctl; + BetterPlayerController? ctl; @override Widget build(BuildContext context) { @@ -23,7 +23,7 @@ class _BetterPlayerState extends State { body: AspectRatio( aspectRatio: 9 / 16, child: BetterPlayer( - controller: ctl, + controller: ctl!, ), ), ); diff --git a/example/lib/main.dart b/example/lib/main.dart index b44a19e..869552b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,5 @@ import 'package:player_test/video_page.dart'; -import 'better_player.dart'; import 'package:flutter/material.dart'; import 'package:better_player/better_player.dart'; @@ -61,7 +60,7 @@ class _AppState extends State { child: BetterPlayer(controller: k), onTap: () { print(k.isPlaying()); - if (k.isPlaying()) { + if (k.isPlaying()!) { k.pause(); } else { k.play(); diff --git a/example/lib/video_page.dart b/example/lib/video_page.dart index 947eb65..ef844e1 100644 --- a/example/lib/video_page.dart +++ b/example/lib/video_page.dart @@ -1,154 +1,219 @@ import 'package:better_player/better_player.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart' show rootBundle; -import 'package:http/http.dart'; -import 'dart:typed_data'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; /// Simple stateful widget to reproduce the bug. /// Replaced the App() widget in the main function to run this one instead. -/// -/// The issue can be noticed when the Play Icon will still be there (means that the video should be paused) -/// and the video will continue playing. class VideoPage extends StatefulWidget { - VideoPage({Key key}) : super(key: key); + VideoPage({Key? key}) : super(key: key); @override State createState() => _VideoPageState(); } class _VideoPageState extends State { - ByteData k; - List necStuff; - String path; - bool isPlaying = true; + String? path; + List? videoBytes; + bool isLoading = true; + + // Track current page index + int currentPageIndex = 0; + + // Store controllers with page index as key + Map controllers = {}; + + // Track play state for each video + Map playingStates = {}; + String exampleManifestUrl = 'https://replied-resources.s3.amazonaws.com/transcoded-videos/' 'Sc9xBOOx3cblM82doaUK9rr8xYx1/dd03019b-5390-4378-9952-287122b47944/master.m3u8'; - List but = new List(3); - int cI = 0; - - Future dothing() async { - var content = await rootBundle.load("assets/testtest.mp4"); - Directory directory = await getApplicationDocumentsDirectory(); - this.path = directory.path; - var file = File("${directory.path}/testvideo.mp4"); - file.writeAsBytesSync(content.buffer.asUint8List()); - return "hi"; - } @override void initState() { - dothing().then((ku) { - this.necStuff = File("${this.path}/testvideo.mp4") - .readAsBytesSync() - .buffer - .asUint8List(); - //print(this.necStuff); - }); - but[0] = getController(); super.initState(); + setState(() { + isLoading = true; + }); + prepareVideo(); } - @override - void dispose() { - super.dispose(); - } + Future prepareVideo() async { + try { + var content = await rootBundle.load("assets/testtest.mp4"); + Directory directory = await getApplicationDocumentsDirectory(); + path = directory.path; - BetterPlayerController getController() { - var before = DateTime.now().microsecondsSinceEpoch; - print(this.necStuff == null); - var ctl = BetterPlayerController( - BetterPlayerConfiguration( - placeholderOnTop: true, - placeholder: Center(child: CupertinoActivityIndicator()), - aspectRatio: 9 / 16, - autoPlay: true, - looping: true, - autoDispose: true, - controlsConfiguration: BetterPlayerControlsConfiguration( - showControls: false, - enableOverflowMenu: false, - enablePlayPause: false, - enableMute: false)), - betterPlayerDataSource: BetterPlayerDataSource( - //this.necStuff == null - //? BetterPlayerDataSourceType.network - //: BetterPlayerDataSourceType.memory, - BetterPlayerDataSourceType.network, - //this.necStuff == null ? exampleManifestUrl : "", - //bytes: this.necStuff, - exampleManifestUrl, - videoExtension: "mp4")); - var after = DateTime.now().microsecondsSinceEpoch; - print(after - before); - return ctl; - } + var file = File("${directory.path}/testvideo.mp4"); + file.writeAsBytesSync(content.buffer.asUint8List()); + + videoBytes = file.readAsBytesSync().buffer.asUint8List(); + + // Initialize first controller + controllers[0] = createController(); + playingStates[0] = true; - Future toggleController(BetterPlayerController controller) async { - if (controller.isPlaying()) { - await controller.pause().then((value) => print('Paused')); setState(() { - isPlaying = false; + isLoading = false; }); - } else { - await controller.play().then((value) => print('Played')); + } catch (e) { + print("Error preparing video: $e"); setState(() { - isPlaying = true; + isLoading = false; }); } - print('Controller is playing? ${controller.isPlaying()}'); } - void sleepthing(BetterPlayerController prevCtl) async { - await Future.delayed(Duration(seconds: 1)).then((dynamic k) { - prevCtl?.dispose(); - prevCtl = null; - }); + BetterPlayerController createController() { + return BetterPlayerController( + BetterPlayerConfiguration( + placeholderOnTop: true, + placeholder: Center(child: CupertinoActivityIndicator()), + aspectRatio: 9 / 16, + autoPlay: true, + looping: true, + autoDispose: false, + controlsConfiguration: BetterPlayerControlsConfiguration( + showControls: false, + enableOverflowMenu: false, + enablePlayPause: false, + enableMute: false, + ), + ), + betterPlayerDataSource: BetterPlayerDataSource( + BetterPlayerDataSourceType.file, + "${path}/testvideo.mp4", + bytes: videoBytes, + videoExtension: "mp4", + ), + ); + } + + Future togglePlayPause(int index) async { + final controller = controllers[index]; + if (controller == null) return; + + final isPlaying = await controller.isPlaying() ?? false; + + if (isPlaying) { + await controller.pause(); + if (mounted) { + setState(() { + playingStates[index] = false; + }); + } + print('Paused video $index'); + } else { + await controller.play(); + if (mounted) { + setState(() { + playingStates[index] = true; + }); + } + print('Playing video $index'); + } + } + + @override + void dispose() { + // Clean up all controllers + for (var controller in controllers.values) { + controller.dispose(); + } + super.dispose(); } - Widget getNext(BuildContext ctx, int index) { - but[(index) % 3] = getController(); - var ctl = but[index % 3]; - ctl.play(); + Widget buildVideoPage(BuildContext context, int index) { + // Create controller if it doesn't exist for this index + if (!controllers.containsKey(index)) { + controllers[index] = createController(); + playingStates[index] = true; + } + + // Get the controller for this page + final controller = controllers[index]!; - var prevCtl = but[(index - 1) % 3]; - prevCtl?.pause(); - sleepthing(prevCtl); + // Get play state for this page + final isPlaying = playingStates[index] ?? true; return CupertinoPageScaffold( child: GestureDetector( child: Stack( children: [ - BetterPlayer(controller: ctl), + BetterPlayer(controller: controller), if (!isPlaying) Center( child: Icon( CupertinoIcons.play_arrow_solid, size: 50, - color: CupertinoColors.black, + color: CupertinoColors.white.withOpacity(0.8), ), ) ], ), - onTap: () { - BetterPlayerController.preCache(exampleManifestUrl); - //ctl.isPlaying() ? ctl.pause() : ctl.play(); - }, + onTap: () => togglePlayPause(index), ), ); } + void onPageChanged(int index) { + // Pause previous page's video + if (controllers.containsKey(currentPageIndex)) { + controllers[currentPageIndex]?.pause(); + if (mounted) { + setState(() { + playingStates[currentPageIndex] = false; + }); + } + } + + // Play current page's video + if (controllers.containsKey(index)) { + controllers[index]?.play(); + if (mounted) { + setState(() { + playingStates[index] = true; + currentPageIndex = index; + }); + } + } + + // Clean up controllers that are far away (memory management) + cleanupDistantControllers(index); + } + + void cleanupDistantControllers(int currentIndex) { + // Keep only nearby controllers (current, previous, next) + final keysToKeep = [currentIndex - 1, currentIndex, currentIndex + 1]; + + final keysToDispose = + controllers.keys.where((key) => !keysToKeep.contains(key)).toList(); + + for (final key in keysToDispose) { + controllers[key]?.dispose(); + controllers.remove(key); + playingStates.remove(key); + } + } + @override Widget build(BuildContext context) { return CupertinoApp( - debugShowCheckedModeBanner: false, - home: Directionality( - textDirection: TextDirection.ltr, - child: PageView.builder( - itemBuilder: getNext, - scrollDirection: Axis.vertical, - ))); + debugShowCheckedModeBanner: false, + home: Directionality( + textDirection: TextDirection.ltr, + child: isLoading + ? Center( + child: CupertinoActivityIndicator(), + ) + : PageView.builder( + itemBuilder: buildVideoPage, + scrollDirection: Axis.vertical, + onPageChanged: onPageChanged, + ), + ), + ); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index adb82c7..0dc48a2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,9 +5,10 @@ packages: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.11.0" better_player: dependency: "direct main" description: @@ -19,72 +20,66 @@ packages: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" + version: "1.3.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" csslib: dependency: transitive description: name: csslib - url: "https://pub.dartlang.org" + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "0.17.3" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.dartlang.org" + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.8" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" ffi: dependency: transitive description: name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "2.1.3" flutter: dependency: "direct main" description: flutter @@ -104,142 +99,170 @@ packages: dependency: transitive description: name: flutter_widget_from_html_core - url: "https://pub.dartlang.org" + sha256: f519d7c454f514d54079aca423d6635756bf4ab520ffdc32f770a2a2faf89ef9 + url: "https://pub.dev" source: hosted version: "0.6.2" html: dependency: transitive description: name: html - url: "https://pub.dartlang.org" + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" source: hosted - version: "0.15.0" + version: "0.15.4" http: dependency: "direct main" description: name: http - url: "https://pub.dartlang.org" + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" source: hosted - version: "0.13.4" + version: "1.3.0" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.15.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.9.0" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.dartlang.org" + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - url: "https://pub.dartlang.org" + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" source: hosted - version: "2.0.16" - path_provider_ios: + version: "2.2.15" + path_provider_foundation: dependency: transitive description: - name: path_provider_ios - url: "https://pub.dartlang.org" + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" source: hosted - version: "2.0.10" + version: "2.4.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.7" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - url: "https://pub.dartlang.org" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.1.2" path_provider_windows: dependency: transitive description: name: path_provider_windows - url: "https://pub.dartlang.org" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.0" pedantic: dependency: transitive description: name: pedantic - url: "https://pub.dartlang.org" + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" source: hosted version: "1.11.1" platform: dependency: transitive description: name: platform - url: "https://pub.dartlang.org" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "2.1.8" sky_engine: dependency: transitive description: flutter @@ -249,114 +272,146 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" source: hosted - version: "1.8.2" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" source: hosted - version: "0.4.9" + version: "0.7.2" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" visibility_detector: dependency: transitive description: name: visibility_detector - url: "https://pub.dartlang.org" + sha256: ec932527913f32f65aa01d3a393504240b9e9021ecc77123f017755605e48832 + url: "https://pub.dev" source: hosted version: "0.2.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + url: "https://pub.dev" + source: hosted + version: "14.2.4" wakelock: dependency: transitive description: name: wakelock - url: "https://pub.dartlang.org" + sha256: da22c0789e1f849bec43688a52f2290f4e66268056f7cd77cb71245aef4917a0 + url: "https://pub.dev" source: hosted version: "0.5.6" wakelock_macos: dependency: transitive description: name: wakelock_macos - url: "https://pub.dartlang.org" + sha256: "047c6be2f88cb6b76d02553bca5a3a3b95323b15d30867eca53a19a0a319d4cd" + url: "https://pub.dev" source: hosted version: "0.4.0" wakelock_platform_interface: dependency: transitive description: name: wakelock_platform_interface - url: "https://pub.dartlang.org" + sha256: "1f4aeb81fb592b863da83d2d0f7b8196067451e4df91046c26b54a403f9de621" + url: "https://pub.dev" source: hosted version: "0.3.0" wakelock_web: dependency: transitive description: name: wakelock_web - url: "https://pub.dartlang.org" + sha256: "1b256b811ee3f0834888efddfe03da8d18d0819317f20f6193e2922b41a501b5" + url: "https://pub.dev" source: hosted version: "0.4.0" wakelock_windows: dependency: transitive description: name: wakelock_windows - url: "https://pub.dartlang.org" + sha256: "857f77b3fe6ae82dd045455baa626bc4b93cb9bb6c86bf3f27c182167c3a5567" + url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" win32: dependency: transitive description: name: win32 - url: "https://pub.dartlang.org" + sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4 + url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "3.1.4" xdg_directories: dependency: transitive description: name: xdg_directories - url: "https://pub.dartlang.org" + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" source: hosted - version: "0.2.0+1" + version: "1.1.0" sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a09009d..1a2d715 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: @@ -29,7 +29,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.0 - http: ^0.13.3 + http: ^1.3.0 path_provider: ^2.0.11 dev_dependencies: @@ -50,6 +50,7 @@ flutter: assets: - image/yoyo_logo.png - assets/testtest.mp4 + - assets/testtest2.mp4 # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see diff --git a/ios/Classes/FLTBetterPlayerPlugin.h b/ios/Classes/FLTBetterPlayerPlugin.h deleted file mode 100644 index 7d01b81..0000000 --- a/ios/Classes/FLTBetterPlayerPlugin.h +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import - -@class CacheManager; - -@interface FLTBetterPlayerPlugin : NSObject -@end diff --git a/ios/Classes/FLTBetterPlayerPlugin.m b/ios/Classes/FLTBetterPlayerPlugin.m deleted file mode 100644 index 8a25766..0000000 --- a/ios/Classes/FLTBetterPlayerPlugin.m +++ /dev/null @@ -1,1190 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import "FLTBetterPlayerPlugin.h" -#import -#import -#import -#import -#import -#import - -#if !__has_feature(objc_arc) -#error Code Requires ARC. -#endif - -int64_t FLTCMTimeToMillis(CMTime time) { - if (time.timescale == 0) return 0; - return time.value * 1000 / time.timescale; -} - -int64_t FLTNSTimeIntervalToMillis(NSTimeInterval interval) { - return (int64_t)(interval * 1000.0); -} - -@interface FLTFrameUpdater : NSObject -@property(nonatomic) int64_t textureId; -@property(nonatomic, weak, readonly) NSObject* registry; -- (void)onDisplayLink:(CADisplayLink*)link; -@end - -@implementation FLTFrameUpdater -- (FLTFrameUpdater*)initWithRegistry:(NSObject*)registry { - NSAssert(self, @"super init cannot be nil"); - if (self == nil) return nil; - _registry = registry; - return self; -} - -- (void)onDisplayLink:(CADisplayLink*)link { - [_registry textureFrameAvailable:_textureId]; -} -@end - -@interface FLTBetterPlayer : NSObject -@property(readonly, nonatomic) AVPlayer* player; -@property(readonly, nonatomic) AVPlayerItemVideoOutput* videoOutput; -@property(readonly, nonatomic) CADisplayLink* displayLink; -@property(nonatomic) FlutterEventChannel* eventChannel; -@property(nonatomic) FlutterEventSink eventSink; -@property(nonatomic) CGAffineTransform preferredTransform; -@property(nonatomic, readonly) bool disposed; -@property(nonatomic, readonly) bool isPlaying; -@property(nonatomic) bool isSeeking; -@property(nonatomic) bool isLooping; -@property(nonatomic, readonly) bool isInitialized; -@property(nonatomic, readonly) NSString* key; -@property(nonatomic, readonly) CVPixelBufferRef prevBuffer; -@property(nonatomic, readonly) int failedCount; -@property(nonatomic) AVPlayerLayer* _playerLayer; -@property(nonatomic) bool _observersAdded; -@property(nonatomic) int stalledCount; -@property(nonatomic) float playerRate; -- (void)play; -- (void)pause; -- (void)setIsLooping:(bool)isLooping; -- (void)updatePlayingState; -- (int64_t) duration; -- (int64_t) position; -@end - - -static void* timeRangeContext = &timeRangeContext; -static void* statusContext = &statusContext; -static void* playbackLikelyToKeepUpContext = &playbackLikelyToKeepUpContext; -static void* playbackBufferEmptyContext = &playbackBufferEmptyContext; -static void* playbackBufferFullContext = &playbackBufferFullContext; -static void* presentationSizeContext = &presentationSizeContext; - - -#if TARGET_OS_IOS -API_AVAILABLE(ios(9.0)) -#endif - - -@implementation FLTBetterPlayer -- (instancetype)initWithFrameUpdater:(FLTFrameUpdater*)frameUpdater { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - _isInitialized = false; - _isPlaying = false; - _disposed = false; - _isSeeking = false; - _player = [[AVPlayer alloc] init]; - _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; - - ///Fix for loading large videos - if (@available(iOS 10.0, *)) { - _player.automaticallyWaitsToMinimizeStalling = false; - } - _displayLink = [CADisplayLink displayLinkWithTarget:frameUpdater - selector:@selector(onDisplayLink:)]; - [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; - _displayLink.paused = YES; - self._observersAdded = false; - return self; -} - -- (void)addObservers:(AVPlayerItem*)item { - if (!self._observersAdded){ - [_player addObserver:self forKeyPath:@"rate" options:0 context:nil]; - [item addObserver:self forKeyPath:@"loadedTimeRanges" options:0 context:timeRangeContext]; - [item addObserver:self forKeyPath:@"status" options:0 context:statusContext]; - [item addObserver:self forKeyPath:@"presentationSize" options:0 context:presentationSizeContext]; - [item addObserver:self - forKeyPath:@"playbackLikelyToKeepUp" - options:0 - context:playbackLikelyToKeepUpContext]; - [item addObserver:self - forKeyPath:@"playbackBufferEmpty" - options:0 - context:playbackBufferEmptyContext]; - [item addObserver:self - forKeyPath:@"playbackBufferFull" - options:0 - context:playbackBufferFullContext]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(itemDidPlayToEndTime:) - name:AVPlayerItemDidPlayToEndTimeNotification - object:item]; - self._observersAdded = true; - } -} - -- (void)removeVideoOutput { - _videoOutput = nil; - if (_player.currentItem == nil) { - return; - } - NSArray* outputs = [[_player currentItem] outputs]; - for (AVPlayerItemOutput* output in outputs) { - [[_player currentItem] removeOutput:output]; - } -} - -- (void)clear { - _displayLink.paused = YES; - _isInitialized = false; - _isPlaying = false; - _disposed = false; - _videoOutput = nil; - _failedCount = 0; - _key = nil; - if (_player.currentItem == nil) { - return; - } - - if (_player.currentItem == nil) { - return; - } - - [self removeObservers]; - AVAsset* asset = [_player.currentItem asset]; - [asset cancelLoading]; -} - -- (void) removeObservers{ - if (self._observersAdded){ - [_player removeObserver:self forKeyPath:@"rate" context:nil]; - [[_player currentItem] removeObserver:self forKeyPath:@"status" context:statusContext]; - [[_player currentItem] removeObserver:self forKeyPath:@"presentationSize" context:presentationSizeContext]; - [[_player currentItem] removeObserver:self - forKeyPath:@"loadedTimeRanges" - context:timeRangeContext]; - [[_player currentItem] removeObserver:self - forKeyPath:@"playbackLikelyToKeepUp" - context:playbackLikelyToKeepUpContext]; - [[_player currentItem] removeObserver:self - forKeyPath:@"playbackBufferEmpty" - context:playbackBufferEmptyContext]; - [[_player currentItem] removeObserver:self - forKeyPath:@"playbackBufferFull" - context:playbackBufferFullContext]; - [[NSNotificationCenter defaultCenter] removeObserver:self]; - self._observersAdded = false; - } -} - -- (void)itemDidPlayToEndTime:(NSNotification*)notification { - if (_isLooping) { - // to prevent reloading the entire video again, we seek to 1 - // My guess is that this happens because the first millisecond isn't buffered at the start - // so when we seek to 0, it will buffer as if nothing was previously buffered - [self seekTo:1]; - } else { - if (_eventSink) { - _eventSink(@{@"event" : @"completed", @"key" : _key}); - [ self removeObservers]; - - } - [_player pause]; - _isPlaying = false; - _displayLink.paused = YES; - } -} - - -static inline CGFloat radiansToDegrees(CGFloat radians) { - // Input range [-pi, pi] or [-180, 180] - CGFloat degrees = GLKMathRadiansToDegrees((float)radians); - if (degrees < 0) { - // Convert -90 to 270 and -180 to 180 - return degrees + 360; - } - // Output degrees in between [0, 360[ - return degrees; -}; - -- (AVMutableVideoComposition*)getVideoCompositionWithTransform:(CGAffineTransform)transform - withAsset:(AVAsset*)asset - withVideoTrack:(AVAssetTrack*)videoTrack { - AVMutableVideoCompositionInstruction* instruction = - [AVMutableVideoCompositionInstruction videoCompositionInstruction]; - instruction.timeRange = CMTimeRangeMake(kCMTimeZero, [asset duration]); - AVMutableVideoCompositionLayerInstruction* layerInstruction = - [AVMutableVideoCompositionLayerInstruction - videoCompositionLayerInstructionWithAssetTrack:videoTrack]; - [layerInstruction setTransform:_preferredTransform atTime:kCMTimeZero]; - - AVMutableVideoComposition* videoComposition = [AVMutableVideoComposition videoComposition]; - instruction.layerInstructions = @[ layerInstruction ]; - videoComposition.instructions = @[ instruction ]; - - // If in portrait mode, switch the width and height of the video - CGFloat width = videoTrack.naturalSize.width; - CGFloat height = videoTrack.naturalSize.height; - NSInteger rotationDegrees = - (NSInteger)round(radiansToDegrees(atan2(_preferredTransform.b, _preferredTransform.a))); - if (rotationDegrees == 90 || rotationDegrees == 270) { - width = videoTrack.naturalSize.height; - height = videoTrack.naturalSize.width; - } - videoComposition.renderSize = CGSizeMake(width, height); - - // TODO(@recastrodiaz): should we use videoTrack.nominalFrameRate ? - // Currently set at a constant 30 FPS - videoComposition.frameDuration = CMTimeMake(1, 30); - - return videoComposition; -} - -- (void)addVideoOutput { - if (_player.currentItem == nil) { - return; - } - - if (_videoOutput) { - NSArray* outputs = [[_player currentItem] outputs]; - for (AVPlayerItemOutput* output in outputs) { - if (output == _videoOutput) { - return; - } - } - } - - NSDictionary* pixBuffAttributes = @{ - (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), - (id)kCVPixelBufferIOSurfacePropertiesKey : @{} - }; - _videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:pixBuffAttributes]; - [_player.currentItem addOutput:_videoOutput]; -} - -- (CGAffineTransform)fixTransform:(AVAssetTrack*)videoTrack { - CGAffineTransform transform = videoTrack.preferredTransform; - // TODO(@recastrodiaz): why do we need to do this? Why is the preferredTransform incorrect? - // At least 2 user videos show a black screen when in portrait mode if we directly use the - // videoTrack.preferredTransform Setting tx to the height of the video instead of 0, properly - // displays the video https://github.com/flutter/flutter/issues/17606#issuecomment-413473181 - if (transform.tx == 0 && transform.ty == 0) { - NSInteger rotationDegrees = (NSInteger)round(radiansToDegrees(atan2(transform.b, transform.a))); - NSLog(@"TX and TY are 0. Rotation: %ld. Natural width,height: %f, %f", (long)rotationDegrees, - videoTrack.naturalSize.width, videoTrack.naturalSize.height); - if (rotationDegrees == 90) { - NSLog(@"Setting transform tx"); - transform.tx = videoTrack.naturalSize.height; - transform.ty = 0; - } else if (rotationDegrees == 270) { - NSLog(@"Setting transform ty"); - transform.tx = 0; - transform.ty = videoTrack.naturalSize.width; - } - } - return transform; -} - -- (void)setDataSourceAsset:(NSString*)asset withKey:(NSString*)key overriddenDuration:(int) overriddenDuration{ - NSString* path = [[NSBundle mainBundle] pathForResource:asset ofType:nil]; - return [self setDataSourceURL:[NSURL fileURLWithPath:path] withKey:key withHeaders: @{} withCache: false overriddenDuration:overriddenDuration]; -} - -- (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withHeaders:(NSDictionary*)headers withCache:(BOOL)useCache overriddenDuration:(int) overriddenDuration{ - if (headers == [NSNull null]){ - headers = @{}; - } - AVPlayerItem* item; - if (useCache){ - [KTVHTTPCache downloadSetAdditionalHeaders:headers]; - NSURL *proxyURL = [KTVHTTPCache proxyURLWithOriginalURL:url]; - item = [AVPlayerItem playerItemWithURL:proxyURL]; - } else { - AVURLAsset* asset = [AVURLAsset URLAssetWithURL:url - options:@{@"AVURLAssetHTTPHeaderFieldsKey" : headers}]; - item = [AVPlayerItem playerItemWithAsset:asset]; - if (@available(iOS 10.0, *)) { - double k = 3; - item.preferredForwardBufferDuration = k; - } - } - - if (@available(iOS 10.0, *) && overriddenDuration > 0) { - item.forwardPlaybackEndTime = CMTimeMake(overriddenDuration/1000, 1); - } - - return [self setDataSourcePlayerItem:item withKey:key]; -} - -- (void)setDataSourcePlayerItem:(AVPlayerItem*)item withKey:(NSString*)key{ - _key = key; - _stalledCount = 0; - _playerRate = 1; - [_player replaceCurrentItemWithPlayerItem:item]; - - AVAsset* asset = [item asset]; - void (^assetCompletionHandler)(void) = ^{ - if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) { - NSArray* tracks = [asset tracksWithMediaType:AVMediaTypeVideo]; - if ([tracks count] > 0) { - AVAssetTrack* videoTrack = tracks[0]; - void (^trackCompletionHandler)(void) = ^{ - if (self->_disposed) return; - if ([videoTrack statusOfValueForKey:@"preferredTransform" - error:nil] == AVKeyValueStatusLoaded) { - // Rotate the video by using a videoComposition and the preferredTransform - self->_preferredTransform = [self fixTransform:videoTrack]; - // Note: - // https://developer.apple.com/documentation/avfoundation/avplayeritem/1388818-videocomposition - // Video composition can only be used with file-based media and is not supported for - // use with media served using HTTP Live Streaming. - AVMutableVideoComposition* videoComposition = - [self getVideoCompositionWithTransform:self->_preferredTransform - withAsset:asset - withVideoTrack:videoTrack]; - item.videoComposition = videoComposition; - } - }; - [videoTrack loadValuesAsynchronouslyForKeys:@[ @"preferredTransform" ] - completionHandler:trackCompletionHandler]; - } - } - }; - - [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] completionHandler:assetCompletionHandler]; - [self addObservers:item]; -} - --(void)handleStalled { - if (_player.currentItem.playbackLikelyToKeepUp || - [self availableDuration] - CMTimeGetSeconds(_player.currentItem.currentTime) > 10.0) { - // TODO: Check to see if this play statement actually makes sense. Why would we continue playing? - // Especially if it's already playing? - //[self play]; - } else { - _stalledCount++; - if (_stalledCount > 50){ - _eventSink([FlutterError - errorWithCode:@"VideoError" - message:@"Failed to load video: playback stalled" - details:nil]); - return; - } - [self performSelector:@selector(handleStalled) withObject:nil afterDelay:1]; - } -} - -- (NSTimeInterval) availableDuration -{ - NSArray *loadedTimeRanges = [[_player currentItem] loadedTimeRanges]; - if (loadedTimeRanges.count > 0){ - CMTimeRange timeRange = [[loadedTimeRanges objectAtIndex:0] CMTimeRangeValue]; - Float64 startSeconds = CMTimeGetSeconds(timeRange.start); - Float64 durationSeconds = CMTimeGetSeconds(timeRange.duration); - NSTimeInterval result = startSeconds + durationSeconds; - return result; - } else { - return 0; - } - -} - -- (void)observeValueForKeyPath:(NSString*)path - ofObject:(id)object - change:(NSDictionary*)change - context:(void*)context { - - if ([path isEqualToString:@"rate"]) { - if (_player.rate == 0 && //if player rate dropped to 0 - CMTIME_COMPARE_INLINE(_player.currentItem.currentTime, >, kCMTimeZero) && //if video was started - CMTIME_COMPARE_INLINE(_player.currentItem.currentTime, <, _player.currentItem.duration) && //but not yet finished - _isPlaying) { //instance variable to handle overall state (changed to YES when user triggers playback) - [self handleStalled]; - } - } - - if (context == timeRangeContext) { - if (_eventSink != nil) { - NSMutableArray*>* values = [[NSMutableArray alloc] init]; - for (NSValue* rangeValue in [object loadedTimeRanges]) { - CMTimeRange range = [rangeValue CMTimeRangeValue]; - int64_t start = FLTCMTimeToMillis(range.start); - int64_t end = start + FLTCMTimeToMillis(range.duration); - if (!CMTIME_IS_INVALID(_player.currentItem.forwardPlaybackEndTime)) { - int64_t endTime = FLTCMTimeToMillis(_player.currentItem.forwardPlaybackEndTime); - if (end > endTime){ - end = endTime; - } - } - - [values addObject:@[ @(start), @(end) ]]; - } - _eventSink(@{@"event" : @"bufferingUpdate", @"values" : values, @"key" : _key}); - } - } - else if (context == presentationSizeContext){ - [self onReadyToPlay]; - } - - else if (context == statusContext) { - AVPlayerItem* item = (AVPlayerItem*)object; - switch (item.status) { - case AVPlayerItemStatusFailed: - NSLog(@"Failed to load video:"); - NSLog(item.error.debugDescription); - - if (_eventSink != nil) { - _eventSink([FlutterError - errorWithCode:@"VideoError" - message:[@"Failed to load video: " - stringByAppendingString:[item.error localizedDescription]] - details:nil]); - } - break; - case AVPlayerItemStatusUnknown: - break; - case AVPlayerItemStatusReadyToPlay: - [self onReadyToPlay]; - break; - } - } else if (context == playbackLikelyToKeepUpContext) { - if ([[_player currentItem] isPlaybackLikelyToKeepUp]) { - [self updatePlayingState]; - if (_eventSink != nil) { - _eventSink(@{@"event" : @"bufferingEnd", @"key" : _key}); - } - } - } else if (context == playbackBufferEmptyContext) { - if (_eventSink != nil) { - _eventSink(@{@"event" : @"bufferingStart", @"key" : _key}); - } - } else if (context == playbackBufferFullContext) { - if (_eventSink != nil) { - _eventSink(@{@"event" : @"bufferingEnd", @"key" : _key}); - } - } -} - -- (void)updatePlayingState { - if (!_isInitialized || !_key) { - NSLog(@"not initalized and paused!!"); - _displayLink.paused = YES; - return; - } - if (!self._observersAdded){ - [self addObservers:[_player currentItem]]; - } - - if (_isPlaying) { - if (@available(iOS 10.0, *)) { - [_player playImmediatelyAtRate:1.0]; - _player.rate = _playerRate; - } else { - [_player play]; - _player.rate = _playerRate; - } - } else { - [_player pause]; - } - // set display link appropriately, however, don't pause - // the display link of the video isSeeking, as we don't - // want to block the UI from updating during seeking. - if (_isPlaying) { - _displayLink.paused = !_isPlaying; - } else if (!_isSeeking) { - _displayLink.paused = !_isPlaying; - } -} - -- (void)onReadyToPlay { - if (_eventSink && !_isInitialized && _key) { - if (!_player.currentItem) { - return; - } - if (_player.status != AVPlayerStatusReadyToPlay) { - return; - } - - CGSize size = [_player currentItem].presentationSize; - CGFloat width = size.width; - CGFloat height = size.height; - - - AVAsset *asset = _player.currentItem.asset; - bool onlyAudio = [[asset tracksWithMediaType:AVMediaTypeVideo] count] == 0; - - // The player has not yet initialized. - if (!onlyAudio && height == CGSizeZero.height && width == CGSizeZero.width) { - return; - } - const BOOL isLive = CMTIME_IS_INDEFINITE([_player currentItem].duration); - // The player may be initialized but still needs to determine the duration. - if (isLive == false && [self duration] == 0) { - return; - } - - //Fix from https://github.com/flutter/flutter/issues/66413 - AVPlayerItemTrack *track = [self.player currentItem].tracks.firstObject; - CGSize naturalSize = track.assetTrack.naturalSize; - CGAffineTransform prefTrans = track.assetTrack.preferredTransform; - CGSize realSize = CGSizeApplyAffineTransform(naturalSize, prefTrans); - - _isInitialized = true; - [self addVideoOutput]; - [self updatePlayingState]; - _eventSink(@{ - @"event" : @"initialized", - @"duration" : @([self duration]), - @"width" : @(fabs(realSize.width) ? : width), - @"height" : @(fabs(realSize.height) ? : height), - @"key" : _key - }); - } -} - -- (void)play { - _stalledCount = 0; - _isPlaying = true; - [self updatePlayingState]; - if (@available(iOS 10.0, *)) { - AVPlayerItem *k = [self player].currentItem; - [[self player] replaceCurrentItemWithPlayerItem:k]; - } -} - -- (void)pause { - _isPlaying = false; - [self updatePlayingState]; -} - -- (int64_t)position { - return FLTCMTimeToMillis([_player currentTime]); -} - -- (int64_t)absolutePosition { - return FLTNSTimeIntervalToMillis([[[_player currentItem] currentDate] timeIntervalSince1970]); -} - -- (int64_t)duration { - CMTime time; - if (@available(iOS 13, *)) { - time = [[_player currentItem] duration]; - } else { - time = [[[_player currentItem] asset] duration]; - } - if (!CMTIME_IS_INVALID(_player.currentItem.forwardPlaybackEndTime)) { - time = [[_player currentItem] forwardPlaybackEndTime]; - } - - return FLTCMTimeToMillis(time); -} - -- (void)seekTo:(int)location { - ///When player is playing, pause video, seek to new position and start again. This will prevent issues with seekbar jumps. - // bool wasPlaying = _isPlaying; - // if (wasPlaying){ - // [_player pause]; - // } - _isSeeking = true; - _displayLink.paused = NO; // to see seeking in video output - [_player seekToTime:CMTimeMake(location, 1000) - toleranceBefore:kCMTimeZero - toleranceAfter:kCMTimeZero - completionHandler:^(BOOL finished){ - - // run async query to not run on main UI thread - // really buggy otherwise - dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - dispatch_async(queue, ^{ - // sleep for 2 frames (time is defined by displayLink duration) - [NSThread sleepForTimeInterval:2*_displayLink.duration]; - _isSeeking = false; - // set display link as appropriate - _displayLink.paused = !_isPlaying; - }); - }]; - -} - -- (void)setIsLooping:(bool)isLooping { - _isLooping = isLooping; -} - -- (void)setVolume:(double)volume { - _player.volume = (float)((volume < 0.0) ? 0.0 : ((volume > 1.0) ? 1.0 : volume)); -} - -- (void)setSpeed:(double)speed result:(FlutterResult)result { - if (speed == 1.0 || speed == 0.0) { - _playerRate = 1; - result(nil); - } else if (speed < 0 || speed > 2.0) { - result([FlutterError errorWithCode:@"unsupported_speed" - message:@"Speed must be >= 0.0 and <= 2.0" - details:nil]); - } else if ((speed > 1.0 && _player.currentItem.canPlayFastForward) || - (speed < 1.0 && _player.currentItem.canPlaySlowForward)) { - _playerRate = speed; - result(nil); - } else { - if (speed > 1.0) { - result([FlutterError errorWithCode:@"unsupported_fast_forward" - message:@"This video cannot be played fast forward" - details:nil]); - } else { - result([FlutterError errorWithCode:@"unsupported_slow_forward" - message:@"This video cannot be played slow forward" - details:nil]); - } - } - - if (_isPlaying){ - _player.rate = _playerRate; - } -} - - -- (void)setTrackParameters:(int) width: (int) height: (int)bitrate { - _player.currentItem.preferredPeakBitRate = bitrate; - if (@available(iOS 11.0, *)) { - if (width == 0 && height == 0){ - _player.currentItem.preferredMaximumResolution = CGSizeZero; - } else { - _player.currentItem.preferredMaximumResolution = CGSizeMake(width, height); - } - } -} - -#if TARGET_OS_IOS - -- (void)usePlayerLayer: (CGRect) frame -{ - if( _player ) - { - // Create new controller passing reference to the AVPlayerLayer - self._playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; - UIViewController* vc = [[[UIApplication sharedApplication] keyWindow] rootViewController]; - self._playerLayer.frame = frame; - self._playerLayer.needsDisplayOnBoundsChange = YES; - // [self._playerLayer addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil]; - [vc.view.layer addSublayer:self._playerLayer]; - vc.view.layer.needsDisplayOnBoundsChange = YES; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - }); - } -} -#endif - -#if TARGET_OS_IOS - -- (void) setAudioTrack:(NSString*) name index:(int) index{ - AVMediaSelectionGroup *audioSelectionGroup = [[[_player currentItem] asset] mediaSelectionGroupForMediaCharacteristic: AVMediaCharacteristicAudible]; - NSArray* options = audioSelectionGroup.options; - - - for (int i = 0; i < [options count]; i++) { - AVMediaSelectionOption* option = [options objectAtIndex:i]; - NSArray *metaDatas = [AVMetadataItem metadataItemsFromArray:option.commonMetadata withKey:@"title" keySpace:@"comn"]; - if (metaDatas.count > 0) { - NSString *title = ((AVMetadataItem*)[metaDatas objectAtIndex:0]).stringValue; - if (title == name && index == i ){ - [[_player currentItem] selectMediaOption:option inMediaSelectionGroup: audioSelectionGroup]; - } - } - - } - -} - -- (void)setMixWithOthers:(bool)mixWithOthers { - if (mixWithOthers) { - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback - withOptions:AVAudioSessionCategoryOptionMixWithOthers - error:nil]; - } else { - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; - } -} - - -#endif -// This workaround if you will change dataSource. Flutter engine caches CVPixelBufferRef and if you -// return NULL from method copyPixelBuffer Flutter will use cached CVPixelBufferRef. If you will -// change your datasource you can see frame from previeous video. Thats why we should return -// trasparent frame for this situation -- (CVPixelBufferRef)prevTransparentBuffer { - if (_prevBuffer) { - CVPixelBufferLockBaseAddress(_prevBuffer, 0); - - int bufferWidth = CVPixelBufferGetWidth(_prevBuffer); - int bufferHeight = CVPixelBufferGetHeight(_prevBuffer); - unsigned char* pixel = (unsigned char*)CVPixelBufferGetBaseAddress(_prevBuffer); - - for (int row = 0; row < bufferHeight; row++) { - for (int column = 0; column < bufferWidth; column++) { - pixel[0] = 0; - pixel[1] = 0; - pixel[2] = 0; - pixel[3] = 0; - pixel += 4; - } - } - CVPixelBufferUnlockBaseAddress(_prevBuffer, 0); - return _prevBuffer; - } - return _prevBuffer; -} - - -- (CVPixelBufferRef)copyPixelBuffer { - //Disabled because of black frame issue - /*if (!_videoOutput || !_isInitialized || !_isPlaying || !_key || ![_player currentItem] || - ![[_player currentItem] isPlaybackLikelyToKeepUp]) { - return [self prevTransparentBuffer]; - }*/ - - CMTime outputItemTime = [_videoOutput itemTimeForHostTime:CACurrentMediaTime()]; - if ([_videoOutput hasNewPixelBufferForItemTime:outputItemTime]) { - _failedCount = 0; - _prevBuffer = [_videoOutput copyPixelBufferForItemTime:outputItemTime itemTimeForDisplay:NULL]; - return _prevBuffer; - } else { - // AVPlayerItemVideoOutput.hasNewPixelBufferForItemTime doesn't work correctly - _failedCount++; - if (_failedCount > 100) { - _failedCount = 0; - [self removeVideoOutput]; - [self addVideoOutput]; - } - return NULL; - } -} - -- (void)onTextureUnregistered { - dispatch_async(dispatch_get_main_queue(), ^{ - [self dispose]; - }); -} - -- (FlutterError* _Nullable)onCancelWithArguments:(id _Nullable)arguments { - _eventSink = nil; - return nil; -} - -- (FlutterError* _Nullable)onListenWithArguments:(id _Nullable)arguments - eventSink:(nonnull FlutterEventSink)events { - _eventSink = events; - // TODO(@recastrodiaz): remove the line below when the race condition is resolved: - // https://github.com/flutter/flutter/issues/21483 - // This line ensures the 'initialized' event is sent when the event - // 'AVPlayerItemStatusReadyToPlay' fires before _eventSink is set (this function - // onListenWithArguments is called) - [self onReadyToPlay]; - return nil; -} - -/// This method allows you to dispose without touching the event channel. This -/// is useful for the case where the Engine is in the process of deconstruction -/// so the channel is going to die or is already dead. -- (void)disposeSansEventChannel { - @try{ - [self clear]; - [_displayLink invalidate]; - } - @catch(NSException *exception) { - NSLog(exception.debugDescription); - } -} - -- (void)dispose { - [self disposeSansEventChannel]; - [_eventChannel setStreamHandler:nil]; - _disposed = true; -} - -@end - -@interface FLTBetterPlayerPlugin () -@property(readonly, weak, nonatomic) NSObject* registry; -@property(readonly, weak, nonatomic) NSObject* messenger; -@property(readonly, strong, nonatomic) NSMutableDictionary* players; -@property(readonly, strong, nonatomic) NSObject* registrar; -@property(readonly, strong, nonatomic) CacheManager* cacheManager; -@end - -@implementation FLTBetterPlayerPlugin -NSMutableDictionary* _dataSourceDict; -NSMutableDictionary* _timeObserverIdDict; -NSMutableDictionary* _artworkImageDict; - - -+ (void)registerWithRegistrar:(NSObject*)registrar { - FlutterMethodChannel* channel = - [FlutterMethodChannel methodChannelWithName:@"better_player_channel" - binaryMessenger:[registrar messenger]]; - FLTBetterPlayerPlugin* instance = [[FLTBetterPlayerPlugin alloc] initWithRegistrar:registrar]; - [registrar addMethodCallDelegate:instance channel:channel]; - [registrar publish:instance]; -} - -- (instancetype)initWithRegistrar:(NSObject*)registrar { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - _registry = [registrar textures]; - _messenger = [registrar messenger]; - _registrar = registrar; - _players = [NSMutableDictionary dictionaryWithCapacity:1]; - _timeObserverIdDict = [NSMutableDictionary dictionary]; - _artworkImageDict = [NSMutableDictionary dictionary]; - _dataSourceDict = [NSMutableDictionary dictionary]; - [KTVHTTPCache proxyStart:nil]; - _cacheManager = [[CacheManager alloc] init]; - return self; -} - -- (void)detachFromEngineForRegistrar:(NSObject*)registrar { - for (NSNumber* textureId in _players.allKeys) { - FLTBetterPlayer* player = _players[textureId]; - [player disposeSansEventChannel]; - } - [_players removeAllObjects]; -} - -- (void)onPlayerSetup:(FLTBetterPlayer*)player - frameUpdater:(FLTFrameUpdater*)frameUpdater - result:(FlutterResult)result { - int64_t textureId = [_registry registerTexture:player]; - frameUpdater.textureId = textureId; - FlutterEventChannel* eventChannel = [FlutterEventChannel - eventChannelWithName:[NSString stringWithFormat:@"better_player_channel/videoEvents%lld", - textureId] - binaryMessenger:_messenger]; - [player setMixWithOthers:false]; - [eventChannel setStreamHandler:player]; - player.eventChannel = eventChannel; - _players[@(textureId)] = player; - result(@{@"textureId" : @(textureId)}); -} - -- (void) setupRemoteNotification :(FLTBetterPlayer*) player{ - [self stopOtherUpdateListener:player]; - NSDictionary* dataSource = [_dataSourceDict objectForKey:[self getTextureId:player]]; - BOOL showNotification = false; - id showNotificationObject = [dataSource objectForKey:@"showNotification"]; - if (showNotificationObject != [NSNull null]) { - showNotification = [[dataSource objectForKey:@"showNotification"] boolValue]; - } - NSString* title = dataSource[@"title"]; - NSString* author = dataSource[@"author"]; - NSString* imageUrl = dataSource[@"imageUrl"]; - - if (showNotification){ - [self setRemoteCommandsNotificationActive]; - [self setupRemoteCommands: player]; - [self setupRemoteCommandNotification: player, title, author, imageUrl]; - [self setupUpdateListener: player, title, author, imageUrl]; - } -} - -- (void) setRemoteCommandsNotificationActive{ - [[AVAudioSession sharedInstance] setActive:true error:nil]; - [[UIApplication sharedApplication] beginReceivingRemoteControlEvents]; -} - -- (void) setRemoteCommandsNotificationNotActive{ - if ([_players count] == 0) { - [[AVAudioSession sharedInstance] setActive:false error:nil]; - } - [[UIApplication sharedApplication] endReceivingRemoteControlEvents]; -} - - -- (void) setupRemoteCommands:(FLTBetterPlayer*)player { - MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter]; - [commandCenter.togglePlayPauseCommand setEnabled:YES]; - [commandCenter.playCommand setEnabled:YES]; - [commandCenter.pauseCommand setEnabled:YES]; - [commandCenter.nextTrackCommand setEnabled:NO]; - [commandCenter.previousTrackCommand setEnabled:NO]; - if (@available(iOS 9.1, *)) { - [commandCenter.changePlaybackPositionCommand setEnabled:YES]; - } - - [commandCenter.togglePlayPauseCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { - if (player.isPlaying){ - player.eventSink(@{@"event" : @"play"}); - - } else { - player.eventSink(@{@"event" : @"pause"}); - - } - return MPRemoteCommandHandlerStatusSuccess; - }]; - - [commandCenter.playCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { - player.eventSink(@{@"event" : @"play"}); - return MPRemoteCommandHandlerStatusSuccess; - }]; - - [commandCenter.pauseCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { - player.eventSink(@{@"event" : @"pause"}); - return MPRemoteCommandHandlerStatusSuccess; - }]; - - - - if (@available(iOS 9.1, *)) { - [commandCenter.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { - MPChangePlaybackPositionCommandEvent * playbackEvent = (MPChangePlaybackRateCommandEvent * ) event; - CMTime time = CMTimeMake(playbackEvent.positionTime, 1); - int64_t millis = FLTCMTimeToMillis(time); - [player seekTo: millis]; - player.eventSink(@{@"event" : @"seek", @"position": @(millis)}); - return MPRemoteCommandHandlerStatusSuccess; - }]; - - } -} - -- (void) setupRemoteCommandNotification:(FLTBetterPlayer*)player, NSString* title, NSString* author , NSString* imageUrl{ - float positionInSeconds = player.position /1000; - float durationInSeconds = player.duration/ 1000; - - - NSMutableDictionary * nowPlayingInfoDict = [@{MPMediaItemPropertyArtist: author, - MPMediaItemPropertyTitle: title, - MPNowPlayingInfoPropertyElapsedPlaybackTime: [ NSNumber numberWithFloat : positionInSeconds], - MPMediaItemPropertyPlaybackDuration: [NSNumber numberWithFloat:durationInSeconds], - MPNowPlayingInfoPropertyPlaybackRate: @1, - } mutableCopy]; - - if (imageUrl != [NSNull null]){ - NSString* key = [self getTextureId:player]; - MPMediaItemArtwork* artworkImage = [_artworkImageDict objectForKey:key]; - - if (key != [NSNull null]){ - if (artworkImage){ - [nowPlayingInfoDict setObject:artworkImage forKey:MPMediaItemPropertyArtwork]; - [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfoDict; - - } else { - dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - dispatch_async(queue, ^{ - @try{ - UIImage * tempArtworkImage = nil; - if ([imageUrl rangeOfString:@"http"].location == NSNotFound){ - tempArtworkImage = [UIImage imageWithContentsOfFile:imageUrl]; - } else { - NSURL *nsImageUrl =[NSURL URLWithString:imageUrl]; - tempArtworkImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:nsImageUrl]]; - } - if(tempArtworkImage) - { - MPMediaItemArtwork* artworkImage = [[MPMediaItemArtwork alloc] initWithImage: tempArtworkImage]; - [_artworkImageDict setObject:artworkImage forKey:key]; - [nowPlayingInfoDict setObject:artworkImage forKey:MPMediaItemPropertyArtwork]; - } - [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfoDict; - } - @catch(NSException *exception) { - - } - }); - } - } - } else { - [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfoDict; - } -} - - - -- (NSString*) getTextureId: (FLTBetterPlayer*) player{ - NSArray* temp = [_players allKeysForObject: player]; - NSString* key = [temp lastObject]; - return key; -} - -- (void) setupUpdateListener:(FLTBetterPlayer*)player,NSString* title, NSString* author,NSString* imageUrl { - id _timeObserverId = [player.player addPeriodicTimeObserverForInterval:CMTimeMake(1, 1) queue:NULL usingBlock:^(CMTime time){ - [self setupRemoteCommandNotification:player, title, author, imageUrl]; - }]; - - NSString* key = [self getTextureId:player]; - [ _timeObserverIdDict setObject:_timeObserverId forKey: key]; -} - - -- (void) disposeNotificationData: (FLTBetterPlayer*)player{ - NSString* key = [self getTextureId:player]; - id _timeObserverId = _timeObserverIdDict[key]; - [_timeObserverIdDict removeObjectForKey: key]; - [_artworkImageDict removeObjectForKey:key]; - if (_timeObserverId){ - [player.player removeTimeObserver:_timeObserverId]; - _timeObserverId = nil; - } - [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = @{}; -} - -- (void) stopOtherUpdateListener: (FLTBetterPlayer*) player{ - NSString* currentPlayerTextureId = [self getTextureId:player]; - for (NSString* textureId in _timeObserverIdDict.allKeys) { - if (currentPlayerTextureId == textureId){ - continue; - } - - id timeObserverId = [_timeObserverIdDict objectForKey:textureId]; - FLTBetterPlayer* playerToRemoveListener = [_players objectForKey:textureId]; - [playerToRemoveListener.player removeTimeObserver: timeObserverId]; - } - [_timeObserverIdDict removeAllObjects]; - -} - - -- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - - - if ([@"init" isEqualToString:call.method]) { - // Allow audio playback when the Ring/Silent switch is set to silent - for (NSNumber* textureId in _players) { - [_registry unregisterTexture:[textureId unsignedIntegerValue]]; - [_players[textureId] dispose]; - } - - [_players removeAllObjects]; - result(nil); - } else if ([@"create" isEqualToString:call.method]) { - FLTFrameUpdater* frameUpdater = [[FLTFrameUpdater alloc] initWithRegistry:_registry]; - FLTBetterPlayer* player = [[FLTBetterPlayer alloc] initWithFrameUpdater:frameUpdater]; - [self onPlayerSetup:player frameUpdater:frameUpdater result:result]; - } else { - NSDictionary* argsMap = call.arguments; - int64_t textureId = ((NSNumber*)argsMap[@"textureId"]).unsignedIntegerValue; - FLTBetterPlayer* player = _players[@(textureId)]; - if ([@"setDataSource" isEqualToString:call.method]) { - [player clear]; - // This call will clear cached frame because we will return transparent frame - [_registry textureFrameAvailable:textureId]; - - NSDictionary* dataSource = argsMap[@"dataSource"]; - [_dataSourceDict setObject:dataSource forKey:[self getTextureId:player]]; - NSString* assetArg = dataSource[@"asset"]; - NSString* uriArg = dataSource[@"uri"]; - NSString* key = dataSource[@"key"]; - NSDictionary* headers = dataSource[@"headers"]; - int overriddenDuration = 0; - if ([dataSource objectForKey:@"overriddenDuration"] != [NSNull null]){ - overriddenDuration = [dataSource[@"overriddenDuration"] intValue]; - } - - BOOL useCache = false; - id useCacheObject = [dataSource objectForKey:@"useCache"]; - if (useCacheObject != [NSNull null]) { - useCache = [[dataSource objectForKey:@"useCache"] boolValue]; - } - - if (headers == nil){ - headers = @{}; - } - if (assetArg) { - NSString* assetPath; - NSString* package = dataSource[@"package"]; - if (![package isEqual:[NSNull null]]) { - assetPath = [_registrar lookupKeyForAsset:assetArg fromPackage:package]; - } else { - assetPath = [_registrar lookupKeyForAsset:assetArg]; - } - [player setDataSourceAsset:assetPath withKey:key overriddenDuration:overriddenDuration]; - } else if (uriArg) { - if ([uriArg hasPrefix:@"file://"]) { - [player setDataSourceURL:[NSURL URLWithString:uriArg] withKey:key withHeaders:headers withCache: useCache overriddenDuration:overriddenDuration]; - } else { - NSURL *proxyURL = [_cacheManager getCacheUrl:uriArg]; - [player setDataSourceURL:proxyURL withKey:key withHeaders:headers withCache: useCache overriddenDuration:overriddenDuration]; - } - } else { - result(FlutterMethodNotImplemented); - } - result(nil); - } else if ([@"dispose" isEqualToString:call.method]) { - [player clear]; - [self disposeNotificationData:player]; - [self setRemoteCommandsNotificationNotActive]; - [_registry unregisterTexture:textureId]; - [_players removeObjectForKey:@(textureId)]; - // If the Flutter contains https://github.com/flutter/engine/pull/12695, - // the `player` is disposed via `onTextureUnregistered` at the right time. - // Without https://github.com/flutter/engine/pull/12695, there is no guarantee that the - // texture has completed the un-reregistration. It may leads a crash if we dispose the - // `player` before the texture is unregistered. We add a dispatch_after hack to make sure the - // texture is unregistered before we dispose the `player`. - // - // TODO(cyanglaz): Remove this dispatch block when - // https://github.com/flutter/flutter/commit/8159a9906095efc9af8b223f5e232cb63542ad0b is in - // stable And update the min flutter version of the plugin to the stable version. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - if (!player.disposed) { - [player dispose]; - } - }); - if ([_players count] == 0) { - [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; - } - result(nil); - } else if ([@"setLooping" isEqualToString:call.method]) { - [player setIsLooping:[argsMap[@"looping"] boolValue]]; - result(nil); - } else if ([@"setVolume" isEqualToString:call.method]) { - [player setVolume:[argsMap[@"volume"] doubleValue]]; - result(nil); - } else if ([@"play" isEqualToString:call.method]) { - [self setupRemoteNotification:player]; - [player play]; - result(nil); - } else if ([@"position" isEqualToString:call.method]) { - result(@([player position])); - } else if ([@"absolutePosition" isEqualToString:call.method]) { - result(@([player absolutePosition])); - } else if ([@"seekTo" isEqualToString:call.method]) { - [player seekTo:[argsMap[@"location"] intValue]]; - result(nil); - } else if ([@"pause" isEqualToString:call.method]) { - [player pause]; - result(nil); - } else if ([@"setTrackParameters" isEqualToString:call.method]) { - int width = [argsMap[@"width"] intValue]; - int height = [argsMap[@"height"] intValue]; - int bitrate = [argsMap[@"bitrate"] intValue]; - - [player setTrackParameters:width: height : bitrate]; - result(nil); - } else if ([@"setAudioTrack" isEqualToString:call.method]){ - NSString* name = argsMap[@"name"]; - int index = [argsMap[@"index"] intValue]; - [player setAudioTrack:name index: index]; - } else if ([@"setMixWithOthers" isEqualToString:call.method]){ - [player setMixWithOthers:[argsMap[@"mixWithOthers"] boolValue]]; - } else if ([@"clearCache" isEqualToString:call.method]){ - [KTVHTTPCache cacheDeleteAllCaches]; - } else if ([@"preCache" isEqualToString:call.method]) { - NSString* url = argsMap[@"dataSource"]; - [_cacheManager preCache:url]; - } else { - result(FlutterMethodNotImplemented); - } - } -} -@end diff --git a/ios/Classes/FLTBetterPlayerPlugin.swift b/ios/Classes/FLTBetterPlayerPlugin.swift new file mode 100644 index 0000000..73d5a13 --- /dev/null +++ b/ios/Classes/FLTBetterPlayerPlugin.swift @@ -0,0 +1,1482 @@ +import Flutter +import AVFoundation +import GLKit +import KTVHTTPCache +import MediaPlayer +import AVKit + +// Helper functions + +// This function converts a CMTime object +// (used in AVFoundation to represent time values) into an +// integer value representing milliseconds (Int64). +func FLTCMTimeToMillis(_ time: CMTime) -> Int64 { + if time.timescale == 0 { return 0 } + return Int64(time.value * 1000 / Int64(time.timescale)) +} + +// This function converts a TimeInterval +// (a Double representing time in seconds) into an integer value +// representing milliseconds (Int64). +func FLTNSTimeIntervalToMillis(_ interval: TimeInterval) -> Int64 { + return Int64(interval * 1000.0) +} + + +// FLTFrameUpdater +// This class is a helper class designed to manage frame updates for a video texture in a Flutter application. +// It works in conjunction with the FlutterTextureRegistry to notify Flutter when a new video frame is available for rendering. +class FLTFrameUpdater: NSObject { + // An identifier (Int64) for the texture associated with the video. This ID is used to notify Flutter about frame updates for the specific texture. + var textureId: Int64 = 0 + + // A weak reference to the FlutterTextureRegistry, which is responsible for managing textures in the Flutter engine. + weak var registry: FlutterTextureRegistry? + + // This method initializes the FLTFrameUpdater with a reference to the FlutterTextureRegistry. + // This allows the class to notify Flutter when a new frame is available. + init(registry: FlutterTextureRegistry) { + self.registry = registry + super.init() + } + + // This method is called periodically by a CADisplayLink, which is a timer that synchronizes with the display's refresh rate (e.g., 60Hz). + @objc func onDisplayLink(_ link: CADisplayLink) { + // The textureFrameAvailable function of the FlutterTextureRegistry is called with the textureId. + // This notifies Flutter that a new frame is available for the texture, prompting Flutter to render the updated frame. + registry?.textureFrameAvailable(textureId) + } +} + +// KVO Context Pointers +// These pointers are used for Key-Value Observing to monitor changes in the player's properties +private var rateContext = 0 +private var timeRangeContext = 0 +private var statusContext = 0 +private var playbackLikelyToKeepUpContext = 0 +private var playbackBufferEmptyContext = 0 +private var playbackBufferFullContext = 0 +private var presentationSizeContext = 0 + + +// FLTBetterPlayer +// This class implements the core video player functionality and interfaces with Flutter +class FLTBetterPlayer: NSObject, FlutterTexture, FlutterStreamHandler { + // Properties + private(set) var player: AVPlayer // The AVPlayer instance that handles media playback + private(set) var videoOutput: AVPlayerItemVideoOutput? // Outputs video frames for rendering + private(set) var displayLink: CADisplayLink // Synchronizes frame updates with the display refresh rate + var eventChannel: FlutterEventChannel? // Channel for sending events to Flutter + var eventSink: FlutterEventSink? // Sink for sending events through the eventChannel + var preferredTransform: CGAffineTransform = .identity // Transform for adjusting video orientation + private(set) var disposed: Bool = false // Flag indicating if the player has been disposed + private(set) var isPlaying: Bool = false // Flag indicating if video is currently playing + var isSeeking: Bool = false // Flag indicating if seeking operation is in progress + var isLooping: Bool = false // Flag indicating if video should loop + private(set) var isInitialized: Bool = false // Flag indicating if player is initialized + private(set) var key: String? // Unique identifier for the video + private(set) var prevBuffer: CVPixelBuffer? // Previous video frame buffer + private(set) var failedCount: Int = 0 // Counter for tracking frame retrieval failures + var _playerLayer: AVPlayerLayer? // Layer for rendering video when not using texture + var _observersAdded: Bool = false // Flag indicating if KVO observers have been added + var stalledCount: Int = 0 // Counter for tracking stall events + var playerRate: Float = 1.0 // Playback speed rate + weak var frameUpdater: FLTFrameUpdater? // Reference to the frame updater + + // Initialization + init(frameUpdater: FLTFrameUpdater) { + self.player = AVPlayer() + self.player.actionAtItemEnd = .none + isInitialized = false + isPlaying = false + disposed = false + isSeeking = false + self.frameUpdater = frameUpdater + + // Create display link to synchronize with screen refresh rate + self.displayLink = CADisplayLink(target: frameUpdater, selector: #selector(FLTFrameUpdater.onDisplayLink)) + + super.init() + + // Fix for loading large videos - prevents automatic stalling for buffering + if #available(iOS 10.0, *) { + player.automaticallyWaitsToMinimizeStalling = false + } + + // Add the display link to the current run loop + displayLink.add(to: RunLoop.current, forMode: .common) + displayLink.isPaused = true + _observersAdded = false + } + + // Returns the texture identifier assigned to this player + func textureId() -> Int64 { + return frameUpdater?.textureId ?? 0 + } + + // Observer Management + // Adds Key-Value Observers to monitor player and item state changes + func addObservers(to item: AVPlayerItem) { + if !_observersAdded { + // Add observers for player and item properties to track state changes + player.addObserver(self, forKeyPath: "rate", options: [], context: &rateContext) + item.addObserver(self, forKeyPath: "loadedTimeRanges", options: [], context: &timeRangeContext) + item.addObserver(self, forKeyPath: "status", options: [], context: &statusContext) + item.addObserver(self, forKeyPath: "presentationSize", options: [], context: &presentationSizeContext) + item.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: [], context: &playbackLikelyToKeepUpContext) + item.addObserver(self, forKeyPath: "playbackBufferEmpty", options: [], context: &playbackBufferEmptyContext) + item.addObserver(self, forKeyPath: "playbackBufferFull", options: [], context: &playbackBufferFullContext) + + // Add notification observer for when the video finishes playing + NotificationCenter.default.addObserver( + self, + selector: #selector(itemDidPlayToEndTime), + name: .AVPlayerItemDidPlayToEndTime, + object: item + ) + _observersAdded = true + } + } + + // Removes the video output from the current item + func removeVideoOutput() { + videoOutput = nil + guard let currentItem = player.currentItem else { return } + + // Remove all outputs from the current item + for output in currentItem.outputs { + currentItem.remove(output) + } + } + + // Resets the player state and cleans up resources + func clear() { + displayLink.isPaused = true + isInitialized = false + isPlaying = false + disposed = false + videoOutput = nil + failedCount = 0 + key = nil + + guard let currentItem = player.currentItem else { return } + + removeObservers() + currentItem.asset.cancelLoading() + } + + // Removes all observers to prevent memory leaks + func removeObservers() { + if _observersAdded { + // Remove all KVO observers + player.removeObserver(self, forKeyPath: "rate", context: &rateContext) + + if let currentItem = player.currentItem { + currentItem.removeObserver(self, forKeyPath: "status", context: &statusContext) + currentItem.removeObserver(self, forKeyPath: "presentationSize", context: &presentationSizeContext) + currentItem.removeObserver(self, forKeyPath: "loadedTimeRanges", context: &timeRangeContext) + currentItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp", context: &playbackLikelyToKeepUpContext) + currentItem.removeObserver(self, forKeyPath: "playbackBufferEmpty", context: &playbackBufferEmptyContext) + currentItem.removeObserver(self, forKeyPath: "playbackBufferFull", context: &playbackBufferFullContext) + } + + NotificationCenter.default.removeObserver(self) + _observersAdded = false + } + } + + // Playback Control + // Handles end of playback - either loops or sends completion event + @objc func itemDidPlayToEndTime(_ notification: Notification) { + if isLooping { + // To prevent reloading the entire video again, we seek to 1ms + // This happens because the first millisecond isn't buffered at the start + // so when we seek to 0, it will buffer as if nothing was previously buffered + seekTo(1) + } else { + if let eventSink = eventSink, let key = key { + // Notify Flutter that playback has completed + eventSink(["event": "completed", "key": key]) + removeObservers() + } + player.pause() + isPlaying = false + displayLink.isPaused = true + } + } + + // Helper function to convert radians to degrees + fileprivate func radiansToDegrees(_ radians: CGFloat) -> CGFloat { + let degrees = GLKMathRadiansToDegrees(Float(radians)) + if degrees < 0 { + return CGFloat(degrees) + 360 + } + // Output degrees in between [0, 360) + return CGFloat(degrees) + } + + // Video Composition + // Creates a video composition to handle video orientation/transformation + func getVideoComposition(withTransform transform: CGAffineTransform, + withAsset asset: AVAsset, + withVideoTrack videoTrack: AVAssetTrack) -> AVMutableVideoComposition { + // Create composition instruction for the full video duration + let instruction = AVMutableVideoCompositionInstruction() + instruction.timeRange = CMTimeRange(start: .zero, duration: asset.duration) + + // Create layer instruction with the preferred transform + let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) + layerInstruction.setTransform(preferredTransform, at: .zero) + + // Create and configure the video composition + let videoComposition = AVMutableVideoComposition() + instruction.layerInstructions = [layerInstruction] + videoComposition.instructions = [instruction] + + // If in portrait mode, switch the width and height of the video + var width = videoTrack.naturalSize.width + var height = videoTrack.naturalSize.height + let rotationDegrees = + Int(round(radiansToDegrees(atan2(preferredTransform.b, preferredTransform.a)))) + + if rotationDegrees == 90 || rotationDegrees == 270 { + width = videoTrack.naturalSize.height + height = videoTrack.naturalSize.width + } + videoComposition.renderSize = CGSize(width: width, height: height) + + videoComposition.frameDuration = CMTime(value: 1, timescale: 30) + + return videoComposition + } + + // Video Output Configuration + // Adds a video output to the current player item for frame retrieval + func addVideoOutput() { + guard let currentItem = player.currentItem else { + return + } + + // Check if video output is already added to avoid duplication + if let videoOutput = videoOutput { + let outputs = currentItem.outputs + for output in outputs { + if output === videoOutput { + return + } + } + } + + // Configure pixel buffer attributes for the video output + let pixBuffAttributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferIOSurfacePropertiesKey as String: [:] + ] + + // Create and add the video output to the player item + videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixBuffAttributes) + if let videoOutput = videoOutput { + currentItem.add(videoOutput) + } + } + + // Fixes the transform for video orientation based on track metadata + func fixTransform(_ videoTrack: AVAssetTrack) -> CGAffineTransform { + var transform = videoTrack.preferredTransform + if transform.tx == 0 && transform.ty == 0 { + let rotationDegrees = Int(round(radiansToDegrees(atan2(transform.b, transform.a)))) + NSLog("TX and TY are 0. Rotation: %d. Natural width,height: %f, %f", rotationDegrees, + videoTrack.naturalSize.width, videoTrack.naturalSize.height) + + // Fix transform for different rotation angles + if rotationDegrees == 90 { + NSLog("Setting transform tx") + transform.tx = videoTrack.naturalSize.height + transform.ty = 0 + } else if rotationDegrees == 270 { + NSLog("Setting transform ty") + transform.tx = 0 + transform.ty = videoTrack.naturalSize.width + } + } + return transform + } + + // Data Source Setup + // Sets up a local asset file as the data source + func setDataSourceAsset(_ asset: String, withKey key: String, overriddenDuration: Int) { + let path = Bundle.main.path(forResource: asset, ofType: nil) + if let path = path { + let url = URL(fileURLWithPath: path) + setDataSourceURL(url, withKey: key, withHeaders: [:], withCache: false, overriddenDuration: overriddenDuration) + } + } + + // Sets up a URL as the data source, handling caching and custom headers + func setDataSourceURL(_ url: URL, withKey key: String, withHeaders headers: [String: String], withCache useCache: Bool, overriddenDuration: Int) { + var headersDictionary = headers + if headers is NSNull { + headersDictionary = [:] + } + + let item: AVPlayerItem + + if useCache { + // Use KTVHTTPCache for caching if enabled + KTVHTTPCache.downloadSetAdditionalHeaders(headersDictionary) + if let proxyURL = KTVHTTPCache.proxyURL(withOriginalURL: url) { + item = AVPlayerItem(url: proxyURL) + } else { + item = AVPlayerItem(url: url) // Fallback to the original URL if proxyURL is nil + } + } else { + // Convert headers dictionary to [String: String] format + let stringHeaders = headersDictionary.reduce(into: [String: String]()) { result, element in + if let stringValue = element.value as? String { + result[element.key] = stringValue + } + } + + // Create asset with custom HTTP headers + let asset = AVURLAsset( + url: url, + options: ["AVURLAssetHTTPHeaderFieldsKey": stringHeaders] + ) + item = AVPlayerItem(asset: asset) + if #available(iOS 10.0, *) { + let k = 3.0 + item.preferredForwardBufferDuration = k + } + } + + // Set custom duration if provided (iOS 10+ only) + if #available(iOS 10.0, *), overriddenDuration > 0 { + // Convert overriddenDuration from milliseconds to a proper CMTime + item.forwardPlaybackEndTime = CMTimeMake(value: Int64(overriddenDuration), timescale: 1000) + } + + setDataSourcePlayerItem(item, withKey: key) + } + + // Sets up a player item as the data source and configures it + func setDataSourcePlayerItem(_ item: AVPlayerItem, withKey key: String) { + self.key = key + stalledCount = 0 + playerRate = 1.0 + player.replaceCurrentItem(with: item) + + let asset = item.asset + + // Completion handler for asset loading + let assetCompletionHandler: () -> Void = { [weak self] in + guard let self = self else { return } + + if asset.statusOfValue(forKey: "tracks", error: nil) == .loaded { + let tracks = asset.tracks(withMediaType: .video) + if !tracks.isEmpty { + let videoTrack = tracks[0] + + // Completion handler for video track loading + let trackCompletionHandler: () -> Void = { [weak self] in + guard let self = self, !self.disposed else { return } + + if videoTrack.statusOfValue(forKey: "preferredTransform", error: nil) == .loaded { + // Rotate the video by using a videoComposition and the preferredTransform + self.preferredTransform = self.fixTransform(videoTrack) + + // Use the asset's duration explicitly + let videoComposition = self.getVideoComposition( + withTransform: self.preferredTransform, + withAsset: asset, + withVideoTrack: videoTrack + ) + item.videoComposition = videoComposition + } + } + + // Load video track properties asynchronously + videoTrack.loadValuesAsynchronously(forKeys: ["preferredTransform"], + completionHandler: trackCompletionHandler) + } + } + } + + // Load asset properties asynchronously + asset.loadValuesAsynchronously(forKeys: ["tracks"], completionHandler: assetCompletionHandler) + addObservers(to: item) + } + + // Stall Handling + // Handles video playback stalls + @objc func handleStalled() { + if player.currentItem?.isPlaybackLikelyToKeepUp == true || + availableDuration() - CMTimeGetSeconds(player.currentItem?.currentTime() ?? .zero) > 10.0 { + // If playback is likely to keep up or we have more than 10 seconds buffered, + // we don't need to do anything special + } else { + stalledCount += 1 + if stalledCount > 50 { + // Too many stalls, report error to Flutter + eventSink?(FlutterError( + code: "VideoError", + message: "Failed to load video: playback stalled", + details: nil) + ) + return + } + // Recursively check for stall again after 1 second + perform(#selector(handleStalled), with: nil, afterDelay: 1.0) + } + } + + // Calculates available playback duration based on loaded time ranges + func availableDuration() -> TimeInterval { + guard let currentItem = player.currentItem, + let loadedTimeRanges = currentItem.loadedTimeRanges as? [NSValue], + loadedTimeRanges.count > 0 else { + return 0 + } + + let timeRange = loadedTimeRanges[0].timeRangeValue + let startSeconds = CMTimeGetSeconds(timeRange.start) + let durationSeconds = CMTimeGetSeconds(timeRange.duration) + let result = startSeconds + durationSeconds + + return result + } + + // KVO Observation + // Handles all Key-Value Observation callbacks + override func observeValue(forKeyPath path: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer?) { + guard let path = path else { return } + + if context == &rateContext { + // Handle changes to playback rate (detect stalls) + if player.rate == 0, // if player rate dropped to 0 + let currentTime = player.currentItem?.currentTime(), + let duration = player.currentItem?.duration, + currentTime > CMTime.zero, // video was started + currentTime < duration, // but not yet finished + isPlaying { // instance variable to handle overall state (changed to true when user triggers playback) + handleStalled() + } + } else if context == &timeRangeContext { + // Handle changes to loaded time ranges (buffering progress) + guard let eventSink = eventSink, let key = key else { return } + + let values = NSMutableArray() + if let object = object as? AVPlayerItem, let loadedTimeRanges = object.loadedTimeRanges as? [NSValue] { + for rangeValue in loadedTimeRanges { + let range = rangeValue.timeRangeValue + let start = FLTCMTimeToMillis(range.start) + var end = start + FLTCMTimeToMillis(range.duration) + + // Ensure we don't exceed any custom end time + if let forwardPlaybackEndTime = player.currentItem?.forwardPlaybackEndTime, + !CMTIME_IS_INVALID(forwardPlaybackEndTime) { + let endTime = FLTCMTimeToMillis(forwardPlaybackEndTime) + if end > endTime { + end = endTime + } + } + + values.add([NSNumber(value: start), NSNumber(value: end)]) + } + } + // Send buffering update event to Flutter + eventSink(["event": "bufferingUpdate", "values": values, "key": key]) + } else if context == &presentationSizeContext { + // Video size is now known, try to initialize the player + onReadyToPlay() + } else if context == &statusContext { + // Handle player item status changes + guard let item = object as? AVPlayerItem else { return } + + switch item.status { + case .failed: + NSLog("Failed to load video:") + NSLog("%@", item.error.map { String(describing: $0) } ?? "unknown error") + + if let eventSink = eventSink { + let message = "Failed to load video: " + (item.error?.localizedDescription ?? "") + eventSink(FlutterError(code: "VideoError", message: message, details: nil)) + } + case .unknown: + // Still loading, nothing to do yet + break + case .readyToPlay: + // Item is ready to play, initialize the player + onReadyToPlay() + @unknown default: + break + } + } else if context == &playbackLikelyToKeepUpContext { + // Handle playback likely to keep up changes (buffering state) + if player.currentItem?.isPlaybackLikelyToKeepUp == true { + updatePlayingState() + if let eventSink = eventSink, let key = key { + eventSink(["event": "bufferingEnd", "key": key]) + } + } + } else if context == &playbackBufferEmptyContext { + // Handle buffer empty state (start buffering) + if let eventSink = eventSink, let key = key { + eventSink(["event": "bufferingStart", "key": key]) + } + } else if context == &playbackBufferFullContext { + // Handle buffer full state (end buffering) + if let eventSink = eventSink, let key = key { + eventSink(["event": "bufferingEnd", "key": key]) + } + } else { + // Handle unexpected key paths or contexts gracefully + NSLog("Unhandled key path: \(path), context: \(String(describing: context))") + } + } + + // Player Initialization + // Updates playback state based on isPlaying flag + func updatePlayingState() { + guard isInitialized, let key = key else { + NSLog("not initialized and paused!!") + displayLink.isPaused = true + return + } + + // Ensure observers are added if needed + if !_observersAdded { + if let currentItem = player.currentItem { + addObservers(to: currentItem) + } + } + + // Update player playback state based on isPlaying flag + if isPlaying { + if #available(iOS 10.0, *) { + player.playImmediately(atRate: 1.0) + player.rate = playerRate + } else { + player.play() + player.rate = playerRate + } + } else { + player.pause() + } + + // Set display link appropriately, however, don't pause + // the display link of the video isSeeking, as we don't + // want to block the UI from updating during seeking. + if isPlaying { + displayLink.isPaused = !isPlaying + } else if !isSeeking { + displayLink.isPaused = !isPlaying + } + } + + // Called when the player is ready to play (initialized) + func onReadyToPlay() { + guard let eventSink = eventSink, + !isInitialized, + let key = key, + let currentItem = player.currentItem, + player.status == .readyToPlay else { + return + } + + let size = currentItem.presentationSize + let width = size.width + let height = size.height + + let asset = currentItem.asset + let onlyAudio = asset.tracks(withMediaType: .video).isEmpty + + // The player has not yet initialized. + if !onlyAudio && height == CGSize.zero.height && width == CGSize.zero.width { + return + } + + let isLive = CMTIME_IS_INDEFINITE(currentItem.duration) + // The player may be initialized but still needs to determine the duration. + if !isLive && duration() == 0 { + return + } + + // Fix from https://github.com/flutter/flutter/issues/66413 + guard let track = player.currentItem?.tracks.first, + let assetTrack = track.assetTrack else { + return + } + + // Get the natural size and apply the preferred transform to get the real size + let naturalSize = assetTrack.naturalSize + let prefTrans = assetTrack.preferredTransform + let realSize = naturalSize.applying(prefTrans) + + // Mark as initialized and set up for playback + isInitialized = true + addVideoOutput() + updatePlayingState() + + // Send initialized event to Flutter with video dimensions + eventSink([ + "event": "initialized", + "duration": NSNumber(value: duration()), + "width": NSNumber(value: abs(realSize.width) != 0 ? abs(realSize.width) : width), + "height": NSNumber(value: abs(realSize.height) != 0 ? abs(realSize.height) : height), + "key": key + ]) + } + + // Playback Control Methods + // Start playing the video + func play() { + stalledCount = 0 + isPlaying = true + updatePlayingState() + + // iOS 10+ workaround to ensure playback starts correctly + if #available(iOS 10.0, *) { + if let currentItem = player.currentItem { + player.replaceCurrentItem(with: currentItem) + } + } + } + + // Pause the video + func pause() { + isPlaying = false + updatePlayingState() + } + + // Get the current playback position in milliseconds + func position() -> Int64 { + return FLTCMTimeToMillis(player.currentTime()) + } + + // Get the absolute timestamp for live streams + func absolutePosition() -> Int64 { + if let currentDate = player.currentItem?.currentDate() { + return FLTNSTimeIntervalToMillis(currentDate.timeIntervalSince1970) + } + return 0 + } + + // Get the total duration of the video in milliseconds + func duration() -> Int64 { + var time: CMTime + + if #available(iOS 13, *) { + time = player.currentItem?.duration ?? .zero + } else { + time = player.currentItem?.asset.duration ?? .zero + } + + // Use custom end time if set + if let currentItem = player.currentItem, + !CMTIME_IS_INVALID(currentItem.forwardPlaybackEndTime) { + time = currentItem.forwardPlaybackEndTime + } + + return FLTCMTimeToMillis(time) + } + + // Seek to a specific position in milliseconds + func seekTo(_ location: Int) { + isSeeking = true + displayLink.isPaused = false // to see seeking in video output + + // Perform the seek operation + player.seek(to: CMTimeMake(value: Int64(location), timescale: 1000), + toleranceBefore: .zero, + toleranceAfter: .zero) { [weak self] finished in + + // run async query to not run on main UI thread + // really buggy otherwise + let queue = DispatchQueue.global(qos: .default) + queue.async { [weak self] in + guard let self = self else { return } + + // sleep for 2 frames (time is defined by displayLink duration) + Thread.sleep(forTimeInterval: 2 * self.displayLink.duration) + self.isSeeking = false + // set display link as appropriate + self.displayLink.isPaused = !self.isPlaying + } + } + } + + // Set whether the video should loop when it reaches the end + func setIsLooping(_ isLooping: Bool) { + self.isLooping = isLooping + } + + // Set the volume level (0.0 to 1.0) + func setVolume(_ volume: Double) { + player.volume = Float(max(0.0, min(1.0, volume))) + } + + // Set the playback speed + func setSpeed(_ speed: Double, result: @escaping FlutterResult) { + if speed == 1.0 || speed == 0.0 { + playerRate = 1.0 + result(nil) + } else if speed < 0 || speed > 2.0 { + result(FlutterError( + code: "unsupported_speed", + message: "Speed must be >= 0.0 and <= 2.0", + details: nil + )) + } else if (speed > 1.0 && player.currentItem?.canPlayFastForward == true) || + (speed < 1.0 && player.currentItem?.canPlaySlowForward == true) { + playerRate = Float(speed) + result(nil) + } else { + if speed > 1.0 { + result(FlutterError( + code: "unsupported_fast_forward", + message: "This video cannot be played fast forward", + details: nil + )) + } else { + result(FlutterError( + code: "unsupported_slow_forward", + message: "This video cannot be played slow forward", + details: nil + )) + } + } + + // Apply rate if currently playing + if isPlaying { + player.rate = Float(playerRate) + } + } + + // Set track parameters for quality control + func setTrackParameters(width: Int, height: Int, bitrate: Int) { + player.currentItem?.preferredPeakBitRate = Double(bitrate) + + if #available(iOS 11.0, *) { + if width == 0 && height == 0 { + player.currentItem?.preferredMaximumResolution = .zero + } else { + player.currentItem?.preferredMaximumResolution = CGSize(width: width, height: height) + } + } + } + + #if os(iOS) + // Create and configure a player layer for UIKit-based rendering + func usePlayerLayer(frame: CGRect) { + // Create new controller passing reference to the AVPlayerLayer + _playerLayer = AVPlayerLayer(player: player) + + if let rootViewController = UIApplication.shared.keyWindow?.rootViewController { + _playerLayer?.frame = frame + _playerLayer?.needsDisplayOnBoundsChange = true + // [self._playerLayer addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil]; + rootViewController.view.layer.addSublayer(_playerLayer!) + rootViewController.view.layer.needsDisplayOnBoundsChange = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Empty completion handler (keeping the same structure as Obj-C code) + } + } + } + #endif + + #if os(iOS) + // Set the audio track by name and index + func setAudioTrack(_ name: String, index: Int) { + guard let currentItem = player.currentItem, + let audioSelectionGroup = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .audible) else { + return + } + + let asset = currentItem.asset + + let options = audioSelectionGroup.options + + // Find and select the specified audio track + for i in 0.. 0, + let title = metaDatas[0].stringValue, + title == name && index == i { + player.currentItem?.select(option, in: audioSelectionGroup) + } + } + } + + // Set whether audio should mix with other apps' audio + func setMixWithOthers(_ mixWithOthers: Bool) { + do { + if mixWithOthers { + try AVAudioSession.sharedInstance().setCategory(.playback, + options: .mixWithOthers) + } else { + try AVAudioSession.sharedInstance().setCategory(.playback) + } + } catch { + NSLog("Failed to set audio session category: \(error.localizedDescription)") + } + } + #endif + + // Texture Generation + // Create a transparent buffer for when no frame is available + func prevTransparentBuffer() -> CVPixelBuffer? { + if let prevBuffer = prevBuffer { + CVPixelBufferLockBaseAddress(prevBuffer, .init(rawValue: 0)) + + let bufferWidth = CVPixelBufferGetWidth(prevBuffer) + let bufferHeight = CVPixelBufferGetHeight(prevBuffer) + + if let baseAddress = CVPixelBufferGetBaseAddress(prevBuffer) { + var pixel = baseAddress.assumingMemoryBound(to: UInt8.self) + + // Set all pixels to transparent black + for _ in 0.. Unmanaged? { + let outputItemTime = videoOutput?.itemTime(forHostTime: CACurrentMediaTime()) + + guard let outputTime = outputItemTime else { return nil } + + if videoOutput?.hasNewPixelBuffer(forItemTime: outputTime) == true { + failedCount = 0 + if let buffer = videoOutput?.copyPixelBuffer(forItemTime: outputTime, itemTimeForDisplay: nil) { + prevBuffer = buffer + return Unmanaged.passRetained(buffer) + } + return nil + } else { + // AVPlayerItemVideoOutput.hasNewPixelBufferForItemTime doesn't work correctly + failedCount += 1 + if failedCount > 100 { + failedCount = 0 + removeVideoOutput() + addVideoOutput() + } + + // Return the previous buffer if available + if let buffer = prevBuffer { + return Unmanaged.passRetained(buffer) + } + return nil + } + } + + // Texture Lifecycle + // Called when the texture is unregistered + func onTextureUnregistered() { + DispatchQueue.main.async { [weak self] in + self?.dispose() + } + } + + // FlutterStreamHandler Protocol Implementation + // Called when event listening is cancelled + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + // Called when Flutter starts listening for events + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + + // TODO(@recastrodiaz): remove the line below when the race condition is resolved: + // https://github.com/flutter/flutter/issues/21483 + // This line ensures the 'initialized' event is sent when the event + // 'AVPlayerItemStatusReadyToPlay' fires before eventSink is set (this function + // onListen is called) + onReadyToPlay() + + return nil + } + + // Disposal + /// This method allows you to dispose without touching the event channel. This + /// is useful for the case where the Engine is in the process of deconstruction + /// so the channel is going to die or is already dead. + func disposeSansEventChannel() { + do { + clear() + displayLink.invalidate() + } catch let exception as NSException { + NSLog("%@", exception.debugDescription) + } catch { + NSLog("Unknown error during disposal") + } + } + + // Fully dispose of the player and its resources + func dispose() { + disposeSansEventChannel() + eventChannel?.setStreamHandler(nil) + disposed = true + } +} + +// FLTBetterPlayerPlugin +// Main plugin class that interfaces with Flutter +public class FLTBetterPlayerPlugin: NSObject, FlutterPlugin { + // Properties + private weak var registry: FlutterTextureRegistry? + private weak var messenger: FlutterBinaryMessenger? + private var players: [Int64: FLTBetterPlayer] = [:] + private weak var registrar: FlutterPluginRegistrar? + private var cacheManager: CacheManager + + // Static properties that were class variables in Objective-C + private static var dataSourceDict: [String: Any] = [:] + private static var timeObserverIdDict: [String: Any] = [:] + private static var artworkImageDict: [String: MPMediaItemArtwork] = [:] + + // Plugin Registration + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "better_player_channel", + binaryMessenger: registrar.messenger() + ) + let instance = FLTBetterPlayerPlugin(registrar: registrar) + registrar.addMethodCallDelegate(instance, channel: channel) + registrar.publish(instance) + } + + // Initialization + init(registrar: FlutterPluginRegistrar) { + self.registry = registrar.textures() + self.messenger = registrar.messenger() + self.registrar = registrar + self.players = [:] + + // Initialize dictionaries + FLTBetterPlayerPlugin.timeObserverIdDict = [:] + FLTBetterPlayerPlugin.artworkImageDict = [:] + FLTBetterPlayerPlugin.dataSourceDict = [:] + + // Start HTTP cache + do { + try KTVHTTPCache.proxyStart() + } catch { + print("Failed to start KTVHTTPCache proxy: \(error)") + } + + // Initialize cache manager + self.cacheManager = CacheManager() + + super.init() + } + + // Plugin Lifecycle + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + // Clean up all players when the plugin is detached + for (textureId, player) in players { + player.disposeSansEventChannel() + } + players.removeAll() + } + + // Player Setup + // Set up a new player instance and register it with Flutter + func onPlayerSetup(_ player: FLTBetterPlayer, + frameUpdater: FLTFrameUpdater, + result: @escaping FlutterResult) { + guard let registry = registry else { + result(FlutterError(code: "player_setup_failed", + message: "Registry is not available", + details: nil)) + return + } + + // Register the player with the texture registry + let textureId = registry.register(player) + frameUpdater.textureId = textureId + + // Create event channel for this specific texture + let channelName = "better_player_channel/videoEvents\(textureId)" + guard let messenger = messenger else { + print("BinaryMessenger is nil") + return + } + + let eventChannel = FlutterEventChannel( + name: channelName, + binaryMessenger: messenger + ) + + // Configure the player + player.setMixWithOthers(false) + eventChannel.setStreamHandler(player) + player.eventChannel = eventChannel + players[textureId] = player + + // Return the texture ID to Flutter + result(["textureId": NSNumber(value: textureId)]) + } + + // Remote Control and Notification Setup + // Set up remote control notifications for the player + func setupRemoteNotification(for player: FLTBetterPlayer) { + stopOtherUpdateListener(for: player) + + guard let textureId = getTextureId(player: player), + let dataSource = FLTBetterPlayerPlugin.dataSourceDict[textureId] as? [String: Any] else { + return + } + + var showNotification = false + if let showNotificationObject = dataSource["showNotification"], + !(showNotificationObject is NSNull) { + showNotification = (dataSource["showNotification"] as? Bool) ?? false + } + + let title = dataSource["title"] as? String ?? "" + let author = dataSource["author"] as? String ?? "" + let imageUrl = dataSource["imageUrl"] as? String + + if showNotification { + setRemoteCommandsNotificationActive() + setupRemoteCommands(for: player) + setupRemoteCommandNotification(player: player, title: title, author: author, imageUrl: imageUrl) + setupUpdateListener(player: player, title: title, author: author, imageUrl: imageUrl) + } + } + + // Activate the audio session for remote control commands + func setRemoteCommandsNotificationActive() { + do { + try AVAudioSession.sharedInstance().setActive(true) + UIApplication.shared.beginReceivingRemoteControlEvents() + } catch { + NSLog("Failed to activate audio session: %@", error.localizedDescription) + } + } + + // Deactivate the audio session when no more players are active + func setRemoteCommandsNotificationNotActive() { + do { + if players.isEmpty { + try AVAudioSession.sharedInstance().setActive(false) + } + UIApplication.shared.endReceivingRemoteControlEvents() + } catch { + NSLog("Failed to deactivate audio session: %@", error.localizedDescription) + } + } + + // Set up remote control commands (play, pause, etc.) + func setupRemoteCommands(for player: FLTBetterPlayer) { + let commandCenter = MPRemoteCommandCenter.shared() + + // Enable relevant commands + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.playCommand.isEnabled = true + commandCenter.pauseCommand.isEnabled = true + commandCenter.nextTrackCommand.isEnabled = false + commandCenter.previousTrackCommand.isEnabled = false + + if #available(iOS 9.1, *) { + commandCenter.changePlaybackPositionCommand.isEnabled = true + } + + // Add handlers for each command + commandCenter.togglePlayPauseCommand.addTarget { [weak player] event in + guard let player = player else { return .commandFailed } + + if player.isPlaying { + player.eventSink?(["event": "play"]) + } else { + player.eventSink?(["event": "pause"]) + } + return .success + } + + commandCenter.playCommand.addTarget { [weak player] event in + guard let player = player else { return .commandFailed } + + player.eventSink?(["event": "play"]) + return .success + } + + commandCenter.pauseCommand.addTarget { [weak player] event in + guard let player = player else { return .commandFailed } + + player.eventSink?(["event": "pause"]) + return .success + } + + if #available(iOS 9.1, *) { + commandCenter.changePlaybackPositionCommand.addTarget { [weak player] event in + guard let player = player, + let playbackEvent = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + + let time = CMTimeMake(value: Int64(playbackEvent.positionTime), timescale: 1) + let millis = FLTCMTimeToMillis(time) + + player.seekTo(Int(millis)) + player.eventSink?(["event": "seek", "position": NSNumber(value: millis)]) + + return .success + } + } + } + + // Set up the control center/lock screen media info + func setupRemoteCommandNotification(player: FLTBetterPlayer, title: String, author: String, imageUrl: String?) { + let positionInSeconds = Float(player.position()) / 1000.0 + let durationInSeconds = Float(player.duration()) / 1000.0 + + // Create the now playing info dict with basic metadata + var nowPlayingInfoDict: [String: Any] = [ + MPMediaItemPropertyArtist: author, + MPMediaItemPropertyTitle: title, + MPNowPlayingInfoPropertyElapsedPlaybackTime: positionInSeconds, + MPMediaItemPropertyPlaybackDuration: durationInSeconds, + MPNowPlayingInfoPropertyPlaybackRate: 1.0 + ] + + // Handle artwork image if provided + if let imageUrl = imageUrl, !(imageUrl is NSNull) { + if let key = getTextureId(player: player), + !(key is NSNull) { + + if let artworkImage = FLTBetterPlayerPlugin.artworkImageDict[key] { + // Use cached artwork image if available + nowPlayingInfoDict[MPMediaItemPropertyArtwork] = artworkImage + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfoDict + } else { + // Load artwork image asynchronously + let queue = DispatchQueue.global(qos: .default) + queue.async { + do { + var tempArtworkImage: UIImage? + + if (!imageUrl.contains("http")) { + // Local file + tempArtworkImage = UIImage(contentsOfFile: imageUrl) + } else { + // Remote URL + if let nsImageUrl = URL(string: imageUrl), + let imageData = try? Data(contentsOf: nsImageUrl) { + tempArtworkImage = UIImage(data: imageData) + } + } + + if let tempArtworkImage = tempArtworkImage { + let artworkImage = MPMediaItemArtwork(image: tempArtworkImage) + FLTBetterPlayerPlugin.artworkImageDict[key] = artworkImage + nowPlayingInfoDict[MPMediaItemPropertyArtwork] = artworkImage + } + + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfoDict + } + } catch { + // Handle exceptions silently as in Obj-C version + } + } + } + } + } else { + // No artwork image, just set the info dict + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfoDict + } + } + + // Helper to get the texture ID string for a player + func getTextureId(player: FLTBetterPlayer) -> String? { + // Find the key (textureId) for the given player + for (id, p) in players { + if p === player { + return String(id) + } + } + return nil + } + + // Set up periodic updates for lock screen info + func setupUpdateListener(player: FLTBetterPlayer, title: String, author: String, imageUrl: String?) { + let timeObserverId = player.player.addPeriodicTimeObserver( + forInterval: CMTimeMake(value: 1, timescale: 1), + queue: nil + ) { [weak self] time in + guard let self = self else { return } + self.setupRemoteCommandNotification( + player: player, + title: title, + author: author, + imageUrl: imageUrl + ) + } + + // Store the observer for later removal + if let key = getTextureId(player: player) { + FLTBetterPlayerPlugin.timeObserverIdDict[key] = timeObserverId + } + } + + // Clean up notification data for a player + func disposeNotificationData(for player: FLTBetterPlayer) { + guard let key = getTextureId(player: player) else { return } + + if let timeObserverId = FLTBetterPlayerPlugin.timeObserverIdDict[key] { + FLTBetterPlayerPlugin.timeObserverIdDict.removeValue(forKey: key) + FLTBetterPlayerPlugin.artworkImageDict.removeValue(forKey: key) + + player.player.removeTimeObserver(timeObserverId) + } + + // Clear lock screen/control center media info + MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] + } + + // Stop update listeners for other players when a new one is started + func stopOtherUpdateListener(for player: FLTBetterPlayer) { + guard let currentPlayerTextureId = getTextureId(player: player) else { return } + + for textureId in FLTBetterPlayerPlugin.timeObserverIdDict.keys { + // Skip the current player + if currentPlayerTextureId == textureId { + continue + } + + if let timeObserverId = FLTBetterPlayerPlugin.timeObserverIdDict[textureId], + let textureIdInt = Int64(textureId), + let playerToRemoveListener = players[textureIdInt] as? FLTBetterPlayer { + playerToRemoveListener.player.removeTimeObserver(timeObserverId) + } + } + + FLTBetterPlayerPlugin.timeObserverIdDict.removeAll() + } + + // Flutter Method Channel Handler + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "init" { + // Allow audio playback when the Ring/Silent switch is set to silent + for (textureId, player) in players { + registry?.unregisterTexture(textureId) + player.dispose() + } + + players.removeAll() + result(nil) + } else if call.method == "create" { + guard let registry = registry else { + result(FlutterError(code: "player_creation_failed", message: "Registry is not available", details: nil)) + return + } + + let frameUpdater = FLTFrameUpdater(registry: registry) + let player = FLTBetterPlayer(frameUpdater: frameUpdater) + onPlayerSetup(player, frameUpdater: frameUpdater, result: result) + } else { + guard let argsMap = call.arguments as? [String: Any], + let textureIdValue = argsMap["textureId"] as? NSNumber, + let player = players[textureIdValue.int64Value] as? FLTBetterPlayer else { + result(FlutterMethodNotImplemented) + return + } + + let textureId = textureIdValue.int64Value + + switch call.method { + case "setDataSource": + // Clear existing player and prepare for new data source + player.clear() + // This call will clear cached frame because we will return transparent frame + registry?.textureFrameAvailable(textureId) + + guard let dataSource = argsMap["dataSource"] as? [String: Any] else { + result(FlutterMethodNotImplemented) + return + } + + // Store data source info for later reference (e.g., notifications) + if let textureIdString = getTextureId(player: player) { + FLTBetterPlayerPlugin.dataSourceDict[textureIdString] = dataSource + } + + let assetArg = dataSource["asset"] as? String + let uriArg = dataSource["uri"] as? String + let key = dataSource["key"] as? String ?? "" + + var headers = dataSource["headers"] as? [String: String] ?? [:] + + var overriddenDuration = 0 + if let overriddenDurationValue = dataSource["overriddenDuration"], + !(overriddenDurationValue is NSNull) { + overriddenDuration = (dataSource["overriddenDuration"] as? Int) ?? 0 + } + + var useCache = false + if let useCacheObject = dataSource["useCache"], + !(useCacheObject is NSNull) { + useCache = (dataSource["useCache"] as? Bool) ?? false + } + + // Set up data source based on asset or URI + if let assetArg = assetArg { + var assetPath: String? + if let package = dataSource["package"] as? String, + !(package is NSNull) { + assetPath = registrar?.lookupKey(forAsset: assetArg, fromPackage: package) + } else { + assetPath = registrar?.lookupKey(forAsset: assetArg) + } + + if let assetPath = assetPath { + player.setDataSourceAsset(assetPath, withKey: key, overriddenDuration: overriddenDuration) + } + result(nil) + } else if let uriArg = uriArg { + if uriArg.hasPrefix("file://") { + if let url = URL(string: uriArg) { + player.setDataSourceURL(url, withKey: key, withHeaders: headers, withCache: useCache, overriddenDuration: overriddenDuration) + } + } else { + if let proxyURL = cacheManager.getCacheUrl(uriArg as NSString) as? URL { + player.setDataSourceURL(proxyURL, withKey: key, withHeaders: headers, withCache: useCache, overriddenDuration: overriddenDuration) + } + } + result(nil) + } else { + result(FlutterMethodNotImplemented) + } + + case "dispose": + // Dispose the player and clean up resources + player.clear() + disposeNotificationData(for: player) + setRemoteCommandsNotificationNotActive() + registry?.unregisterTexture(textureId) + players.removeValue(forKey: textureId) + + // If the Flutter contains https://github.com/flutter/engine/pull/12695, + // the `player` is disposed via `onTextureUnregistered` at the right time. + // Without https://github.com/flutter/engine/pull/12695, there is no guarantee that the + // texture has completed the un-reregistration. It may leads a crash if we dispose the + // `player` before the texture is unregistered. We add a dispatch_after hack to make sure the + // texture is unregistered before we dispose the `player`. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak player] in + if let player = player, !player.disposed { + player.dispose() + } + } + + if players.isEmpty { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + result(nil) + + case "setLooping": + // Configure looping behavior + if let looping = argsMap["looping"] as? Bool { + player.setIsLooping(looping) + } + result(nil) + + case "setVolume": + // Set player volume + if let volume = argsMap["volume"] as? Double { + player.setVolume(volume) + } + result(nil) + + case "play": + // Start playback and set up remote notifications + setupRemoteNotification(for: player) + player.play() + result(nil) + + case "position": + // Get current playback position + result(NSNumber(value: player.position())) + + case "absolutePosition": + // Get absolute position (for live streams) + result(NSNumber(value: player.absolutePosition())) + + case "seekTo": + // Seek to specific position + if let location = argsMap["location"] as? Int { + player.seekTo(location) + } + result(nil) + + case "pause": + // Pause playback + player.pause() + result(nil) + + case "setTrackParameters": + // Set video quality parameters + let width = argsMap["width"] as? Int ?? 0 + let height = argsMap["height"] as? Int ?? 0 + let bitrate = argsMap["bitrate"] as? Int ?? 0 + + player.setTrackParameters(width: width, height: height, bitrate: bitrate) + result(nil) + + case "setAudioTrack": + // Select specific audio track + if let name = argsMap["name"] as? String, + let index = argsMap["index"] as? Int { + player.setAudioTrack(name, index: index) + } + result(nil) + + case "setMixWithOthers": + // Configure audio mixing behavior + if let mixWithOthers = argsMap["mixWithOthers"] as? Bool { + player.setMixWithOthers(mixWithOthers) + } + result(nil) + + case "clearCache": + // Clear video cache + KTVHTTPCache.cacheDeleteAllCaches() + result(nil) + + case "preCache": + // Pre-cache a video for future playback + if let url = argsMap["dataSource"] as? String { + cacheManager.preCache(url as NSString) + } + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + } +} \ No newline at end of file diff --git a/ios/better_player.podspec b/ios/better_player.podspec index 3108387..fbe0f19 100644 --- a/ios/better_player.podspec +++ b/ios/better_player.podspec @@ -13,7 +13,6 @@ A new flutter plugin project. s.author = { 'Your Company' => 'email@example.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' # KTVHTTPCache s.dependency 'KTVHTTPCache', '~> 2.0.0' diff --git a/pubspec.lock b/pubspec.lock index 9f9a278..d3fcf80 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,81 +1,100 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.11.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" collection: dependency: "direct main" description: name: collection - url: "https://pub.dartlang.org" + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" csslib: dependency: transitive description: name: csslib - url: "https://pub.dartlang.org" + sha256: f857285c8dc0b4f2f77b49a1c083ff8c75223a7549de20f3e607df58cf497a43 + url: "https://pub.dev" source: hosted version: "0.17.0" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - url: "https://pub.dartlang.org" + sha256: caac504f942f41dfadcf45229ce8c47065b93919a12739f20d6173a883c5ec73 + url: "https://pub.dev" source: hosted version: "1.0.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" ffi: dependency: transitive description: name: ffi - url: "https://pub.dartlang.org" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.1.3" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: "9fd2163d866769f60f4df8ac1dc59f52498d810c356fe78022e383dd3c57c0e1" + url: "https://pub.dev" source: hosted version: "6.1.0" flutter: @@ -102,126 +121,200 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - url: "https://pub.dartlang.org" + sha256: "5ac2944b95aea1c3c29dfced964fc83e97283c417c22b1164ec302d2dd0386d2" + url: "https://pub.dev" source: hosted version: "0.6.0-rc.2021030401" html: dependency: transitive description: name: html - url: "https://pub.dartlang.org" + sha256: bfef906cbd4e78ef49ae511d9074aebd1d2251482ef601a280973e8b58b51bbf + url: "https://pub.dev" source: hosted version: "0.15.0" + http: + dependency: transitive + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" intl: dependency: transitive description: name: intl - url: "https://pub.dartlang.org" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" source: hosted - version: "0.17.0" - js: + version: "0.19.0" + leak_tracker: dependency: transitive description: - name: js - url: "https://pub.dartlang.org" + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lint: dependency: "direct dev" description: name: lint - url: "https://pub.dartlang.org" + sha256: "697cff175f613d90e99e0eebe26946f2632d1ae4a4db63756b7ba003667ac4c8" + url: "https://pub.dev" source: hosted version: "1.5.2" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.11.1" meta: dependency: "direct main" description: name: meta - url: "https://pub.dartlang.org" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.15.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.9.0" path_provider: dependency: "direct main" description: name: path_provider - url: "https://pub.dartlang.org" + sha256: aa1b3572707c240d72569ce01756728cf0c8dca0cc381253d8ca2858c13edfe4 + url: "https://pub.dev" source: hosted version: "2.0.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - url: "https://pub.dartlang.org" + sha256: "938d2b6591587bcb009d2109a6ea464fd8fb2a75dc6423171b0d0afb1d27c708" + url: "https://pub.dev" source: hosted version: "2.0.0" path_provider_macos: dependency: transitive description: name: path_provider_macos - url: "https://pub.dartlang.org" + sha256: eb58b896ea3a504f0b0fa7870646bda6935a6f752b2a54df33f97070dacca8d4 + url: "https://pub.dev" source: hosted version: "2.0.0" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - url: "https://pub.dartlang.org" + sha256: c2af5a8a6369992d915f8933dfc23172071001359d17896e83db8be57db8a397 + url: "https://pub.dev" source: hosted version: "2.0.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - url: "https://pub.dartlang.org" + sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.7" pedantic: dependency: "direct main" description: name: pedantic - url: "https://pub.dartlang.org" + sha256: "8f6460c77a98ad2807cd3b98c67096db4286f56166852d0ce5951bb600a63594" + url: "https://pub.dev" source: hosted version: "1.11.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: name: platform - url: "https://pub.dartlang.org" + sha256: ebc79f16b5f6b609aad4a5e63447d4795d16f7adee46e93ed03200848c006735 + url: "https://pub.dev" source: hosted version: "3.0.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - url: "https://pub.dartlang.org" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.8" process: dependency: transitive description: name: process - url: "https://pub.dartlang.org" + sha256: c7b9f7d8a6ee4407ab4f8a7d4a951f8f5659c40df14c0924e2e97c32372e9b14 + url: "https://pub.dev" source: hosted version: "4.1.0" sky_engine: @@ -233,107 +326,130 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" source: hosted - version: "1.8.2" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" source: hosted - version: "0.4.9" + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" visibility_detector: dependency: "direct main" description: name: visibility_detector - url: "https://pub.dartlang.org" + sha256: "8e7e0a5f8cb7dca52518687440a478db6f589ec6e30cb96b965851cadada8063" + url: "https://pub.dev" source: hosted version: "0.2.0-nullsafety.1" - wakelock: - dependency: "direct main" - description: - name: wakelock - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0+2" - wakelock_macos: + vm_service: dependency: transitive description: - name: wakelock_macos - url: "https://pub.dartlang.org" + name: vm_service + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + url: "https://pub.dev" source: hosted - version: "0.1.0" - wakelock_platform_interface: - dependency: transitive + version: "14.2.4" + wakelock_plus: + dependency: "direct main" description: - name: wakelock_platform_interface - url: "https://pub.dartlang.org" + name: wakelock_plus + sha256: b90fbcc8d7bdf3b883ea9706d9d76b9978cb1dfa4351fcc8014d6ec31a493354 + url: "https://pub.dev" source: hosted - version: "0.2.0" - wakelock_web: + version: "1.2.11" + wakelock_plus_platform_interface: dependency: transitive description: - name: wakelock_web - url: "https://pub.dartlang.org" + name: wakelock_plus_platform_interface + sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" + url: "https://pub.dev" source: hosted - version: "0.2.0" - wakelock_windows: + version: "1.2.2" + web: dependency: transitive description: - name: wakelock_windows - url: "https://pub.dartlang.org" + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "1.1.1" win32: dependency: transitive description: name: win32 - url: "https://pub.dartlang.org" + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "5.10.1" xdg_directories: dependency: transitive description: name: xdg_directories - url: "https://pub.dartlang.org" + sha256: "0186b3f2d66be9a12b0295bddcf8b6f8c0b0cc2f85c6287344e2a6366bc28457" + url: "https://pub.dev" source: hosted version: "0.2.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=2.0.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 38cae2d..0744acc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,14 +7,14 @@ authors: homepage: https://github.com/jhomlala/betterplayer environment: - sdk: '>=2.12.0 <3.0.0' + sdk: ">=2.12.0 <3.0.0" flutter: ">=1.10.0" dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 - wakelock: ^0.5.0+2 + wakelock_plus: ^1.2.10 pedantic: ^1.11.0 meta: ^1.3.0 flutter_widget_from_html_core: ^0.6.0-rc.2021030201