From 47e1721f5f105df283018e169fe5a647efa76a4e Mon Sep 17 00:00:00 2001 From: Jakub Date: Mon, 20 Sep 2021 21:18:46 +0200 Subject: [PATCH] August/September changes (#709) * Added expandToFill parameter in BetterPlayerConfiguration * Updated changelog * * Added `BetterPlayerControlsConfiguration.theme` factory for `BetterPlayerControlsConfiguration`. * Added null checks in seek commands in BetterPlayerControlsState. * Fixed issue with live stream where player controls were always visible. * Updated tests * Updated tests * Updated tests * Updated tests * Updated tests * Added tests to CI * Update ci.yml * Updated tests * Updated tests * Updated tests * Updated tests * Updated tests, general refactor * Updated cupertino theme * Fixed iOS seek issue * Fix iOS caching + add iOS feature: preCaching (#670) * Fix iOS caching + add iOS feature: preCaching * Added required changes * Fix compile issues * Added stop pre cache iOS implementation * Updated caching implementation * Updated caching implementation * Updated caching implementation * Updated caching implementation * Updated documentation * Fixed video FPS hardcoded to 30 on iOs (#705) Co-authored-by: Jakub * Updated changelog * set default subtitle from hls (#688) * set default subtitle from hls * remove comments * General refactor * Disabled analysis options. * Flutter 2.5 update * Flutter 2.5 update * Disabled lint * Fixed analyzer * Updated dependencies * Updated version Co-authored-by: themadmrj Co-authored-by: Anton Krasov Co-authored-by: Siloe Bezerra Bispo --- .github/workflows/ci.yml | 12 + CHANGELOG.md | 18 + docs/_coverpage.md | 2 +- docs/cacheconfiguration.md | 17 +- docs/install.md | 2 +- example/ios/Podfile.lock | 49 --- example/ios/Runner.xcodeproj/project.pbxproj | 19 +- example/lib/main.dart | 1 - example/lib/pages/cache_page.dart | 2 +- ios/Classes/BetterPlayer.h | 7 +- ios/Classes/BetterPlayer.m | 99 ++--- ios/Classes/BetterPlayerPlugin.h | 3 +- ios/Classes/BetterPlayerPlugin.m | 109 +++-- ios/Classes/CacheManager.swift | 222 ++++++++++ ios/Classes/CachingPlayerItem.swift | 279 ++++++++++++ ios/better_player.podspec | 4 +- lib/src/asms/better_player_asms_subtitle.dart | 4 + .../better_player_configuration.dart | 76 ++-- .../better_player_controls_configuration.dart | 16 +- .../better_player_controls_state.dart | 38 +- .../better_player_cupertino_controls.dart | 293 ++++++------- .../better_player_material_controls.dart | 83 ++-- lib/src/core/better_player.dart | 12 +- lib/src/core/better_player_controller.dart | 56 ++- lib/src/core/better_player_with_controls.dart | 23 +- lib/src/hls/better_player_hls_utils.dart | 24 +- lib/src/hls/hls_parser/format.dart | 33 +- .../hls/hls_parser/hls_playlist_parser.dart | 17 +- lib/src/hls/hls_parser/util.dart | 7 + .../better_player_playlist_controller.dart | 5 +- .../method_channel_video_player.dart | 6 +- lib/src/video_player/video_player.dart | 28 +- .../video_player_platform_interface.dart | 2 +- pubspec.lock | 28 +- pubspec.yaml | 17 +- test/better_player_controller_test.dart | 406 +++++++++++++++++- test/better_player_controls_test.dart | 43 ++ test/better_player_mock_controller.dart | 8 +- test/better_player_test.dart | 4 - test/better_player_test_utils.dart | 23 + test/mock_method_channel.dart | 38 +- test/mock_video_player_controller.dart | 75 ++++ 42 files changed, 1642 insertions(+), 568 deletions(-) delete mode 100644 example/ios/Podfile.lock create mode 100644 ios/Classes/CacheManager.swift create mode 100644 ios/Classes/CachingPlayerItem.swift create mode 100644 test/better_player_controls_test.dart create mode 100644 test/mock_video_player_controller.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86e225107..875ee6a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,15 @@ jobs: working-directory: example - name: Lint using flutter analyze run: flutter analyze + + + test: + name: Test + runs-on: ubuntu-latest + container: cirrusci/flutter:stable + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Test using flutter test + run: flutter test diff --git a/CHANGELOG.md b/CHANGELOG.md index 459cdc347..38c7eee05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 0.0.74 +* [BREAKING_CHANGE] `nextVideoTimeStreamController` is now marked as private. Please use `nextVideoTimeStream` to access stream. +* [BREAKING_CHANGE] Removed BackdropFilter from cupertino theme. +* [BREAKING_CHANGE] Removed `sigmaX` and `sigmaY` parameters from BetterPlayerControlsConfiguration. +* Added `expandToFill` in `BetterPlayerConfiguration`. +* Added `BetterPlayerControlsConfiguration.theme` factory for `BetterPlayerControlsConfiguration`. +* Added null checks in seek commands in `BetterPlayerControlsState`. +* Added tests. +* Added iOS HLS caching based on HLSCachingReverseProxyServer. +* Added default subtitle support for ASMS HLS data source (by https://github.com/siloebb). +* Fixed issue with live stream where player controls were always visible. +* Fixed iOS seek issue. +* Fixed getting started button link in documentation. +* Changed iOS non-HLS caching implementation based on https://github.com/neekeetab/CachingPlayerItem (by https://github.com/themadmrj). +* Fixed hardcoded 30 FPS on iOS (by https://github.com/antonkrasov). +* Enabled `preCache` and `stopPreCache` for iOS. +* Updated dependencies. + ## 0.0.73 * Added `licenseUrl` support for iOS DRM. * Fixed RTL text direction issue in player controls. diff --git a/docs/_coverpage.md b/docs/_coverpage.md index bcc1f8621..80585b42e 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -12,4 +12,4 @@ - Supports both Android and iOS [GitHub](https://github.com/jhomlala/betterplayer) -[Get Started](#home) \ No newline at end of file +[Get Started](#README) \ No newline at end of file diff --git a/docs/cacheconfiguration.md b/docs/cacheconfiguration.md index 7ad688f86..791227003 100644 --- a/docs/cacheconfiguration.md +++ b/docs/cacheconfiguration.md @@ -40,12 +40,25 @@ Clear all cached data: betterPlayerController.clearCache(); ``` -Start pre cache before playing video (android only): +Start pre cache before playing video: ```dart betterPlayerController.preCache(_betterPlayerDataSource); ``` -Stop running pre cache (android only): +Stop running pre cache: ```dart betterPlayerController.stopPreCache(_betterPlayerDataSource); ``` + +On Android both HLS and non-HLS data sources will work in the same way (by using ExoPlayer internal cache mechanism). On iOS +for HLS stream [HLSCachingReverseProxyServer](https://github.com/StyleShare/HLSCachingReverseProxyServer) is being used, +and for other sources [CachingPlayerItem](https://github.com/neekeetab/CachingPlayerItem) is being used. + +See table below to check which cache options are available on given platform: + +| Feature | Android HLS | Android non-HLS | iOS HLS | iOS non-HLS | +|:-----------------:|:-----------:|:---------------:|:-------:|:-----------:| +| Normal item cache | ✓ | ✓ | ✓ | ✓ | +| Pre cache | ✓ | ✓ | x | ✓ | +| Stop cache | ✓ | ✓ | x | ✓ | + diff --git a/docs/install.md b/docs/install.md index 22a5202ee..55c0d2b78 100644 --- a/docs/install.md +++ b/docs/install.md @@ -4,7 +4,7 @@ ```yaml dependencies: - better_player: ^0.0.73 + better_player: ^0.0.74 ``` 2. Install it diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock deleted file mode 100644 index ccdf95b59..000000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,49 +0,0 @@ -PODS: - - better_player (0.0.1): - - Flutter - - KTVHTTPCache (~> 2.0.0) - - CocoaAsyncSocket (7.6.4) - - Flutter (1.0.0) - - KTVCocoaHTTPServer (1.0.0): - - CocoaAsyncSocket - - KTVHTTPCache (2.0.1): - - KTVCocoaHTTPServer - - path_provider (0.0.1): - - Flutter - - wakelock (0.0.1): - - Flutter - -DEPENDENCIES: - - better_player (from `.symlinks/plugins/better_player/ios`) - - Flutter (from `Flutter`) - - path_provider (from `.symlinks/plugins/path_provider/ios`) - - wakelock (from `.symlinks/plugins/wakelock/ios`) - -SPEC REPOS: - trunk: - - CocoaAsyncSocket - - KTVCocoaHTTPServer - - KTVHTTPCache - -EXTERNAL SOURCES: - better_player: - :path: ".symlinks/plugins/better_player/ios" - Flutter: - :path: Flutter - path_provider: - :path: ".symlinks/plugins/path_provider/ios" - wakelock: - :path: ".symlinks/plugins/wakelock/ios" - -SPEC CHECKSUMS: - better_player: a4383402f457e53720525888c0fc5d337ef6ba11 - CocoaAsyncSocket: 694058e7c0ed05a9e217d1b3c7ded962f4180845 - Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c - KTVCocoaHTTPServer: df8d7b861e603ff8037e9b2138aca2563a6b768d - KTVHTTPCache: 588c3eb16f6bd1e6fde1e230dabfb7bd4e490a4d - path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c - wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f - -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c - -COCOAPODS: 1.10.1 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 639dce5b4..4433338ef 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -264,18 +264,22 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/CocoaAsyncSocket/CocoaAsyncSocket.framework", - "${BUILT_PRODUCTS_DIR}/KTVCocoaHTTPServer/KTVCocoaHTTPServer.framework", - "${BUILT_PRODUCTS_DIR}/KTVHTTPCache/KTVHTTPCache.framework", + "${BUILT_PRODUCTS_DIR}/Cache/Cache.framework", + "${BUILT_PRODUCTS_DIR}/GCDWebServer/GCDWebServer.framework", + "${BUILT_PRODUCTS_DIR}/HLSCachingReverseProxyServer/HLSCachingReverseProxyServer.framework", + "${BUILT_PRODUCTS_DIR}/PINCache/PINCache.framework", + "${BUILT_PRODUCTS_DIR}/PINOperation/PINOperation.framework", "${BUILT_PRODUCTS_DIR}/better_player/better_player.framework", "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework", "${BUILT_PRODUCTS_DIR}/wakelock/wakelock.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaAsyncSocket.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KTVCocoaHTTPServer.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KTVHTTPCache.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Cache.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GCDWebServer.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/HLSCachingReverseProxyServer.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PINCache.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PINOperation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/better_player.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock.framework", @@ -385,6 +389,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -521,6 +526,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -552,6 +558,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", diff --git a/example/lib/main.dart b/example/lib/main.dart index 985506b44..5cd5edd1f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -24,7 +24,6 @@ class MyApp extends StatelessWidget { ], theme: ThemeData( primarySwatch: Colors.green, - accentColor: Colors.green, ), home: WelcomePage(), )); diff --git a/example/lib/pages/cache_page.dart b/example/lib/pages/cache_page.dart index a950c6dd9..2100d0891 100644 --- a/example/lib/pages/cache_page.dart +++ b/example/lib/pages/cache_page.dart @@ -20,7 +20,7 @@ class _CachePageState extends State { ); _betterPlayerDataSource = BetterPlayerDataSource( BetterPlayerDataSourceType.network, - Constants.elephantDreamVideoUrl, + Constants.phantomVideoUrl, cacheConfiguration: BetterPlayerCacheConfiguration( useCache: true, preCacheSize: 10 * 1024 * 1024, diff --git a/ios/Classes/BetterPlayer.h b/ios/Classes/BetterPlayer.h index aceb5833d..ca900b8df 100644 --- a/ios/Classes/BetterPlayer.h +++ b/ios/Classes/BetterPlayer.h @@ -6,7 +6,6 @@ #import #import #import -#import #import #import "BetterPlayerTimeUtils.h" #import "BetterPlayerView.h" @@ -14,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN +@class CacheManager; + @interface BetterPlayer : NSObject @property(readonly, nonatomic) AVPlayer* player; @property(readonly, nonatomic) BetterPlayerEzDrmAssetsLoaderDelegate* loaderDelegate; @@ -44,8 +45,8 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithFrame:(CGRect)frame; - (void)setMixWithOthers:(bool)mixWithOthers; - (void)seekTo:(int)location; -- (void)setDataSourceAsset:(NSString*)asset withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl overriddenDuration:(int) overriddenDuration; -- (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl withHeaders:(NSDictionary*)headers withCache:(BOOL)useCache overriddenDuration:(int) overriddenDuration; +- (void)setDataSourceAsset:(NSString*)asset withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl cacheKey:(NSString*)cacheKey cacheManager:(CacheManager*)cacheManager overriddenDuration:(int) overriddenDuration; +- (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl withHeaders:(NSDictionary*)headers withCache:(BOOL)useCache cacheKey:(NSString*)cacheKey cacheManager:(CacheManager*)cacheManager overriddenDuration:(int) overriddenDuration; - (void)setVolume:(double)volume; - (void)setSpeed:(double)speed result:(FlutterResult)result; - (void) setAudioTrack:(NSString*) name index:(int) index; diff --git a/ios/Classes/BetterPlayer.m b/ios/Classes/BetterPlayer.m index 326e0f2d5..822de3f4c 100644 --- a/ios/Classes/BetterPlayer.m +++ b/ios/Classes/BetterPlayer.m @@ -3,6 +3,7 @@ // found in the LICENSE file. #import "BetterPlayer.h" +#import static void* timeRangeContext = &timeRangeContext; static void* statusContext = &statusContext; @@ -76,11 +77,11 @@ - (void)clear { if (_player.currentItem == nil) { return; } - + if (_player.currentItem == nil) { return; } - + [self removeObservers]; AVAsset* asset = [_player.currentItem asset]; [asset cancelLoading]; @@ -116,7 +117,7 @@ - (void)itemDidPlayToEndTime:(NSNotification*)notification { if (_eventSink) { _eventSink(@{@"event" : @"completed", @"key" : _key}); [ self removeObservers]; - + } } } @@ -143,11 +144,11 @@ - (AVMutableVideoComposition*)getVideoCompositionWithTransform:(CGAffineTransfor [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; @@ -158,10 +159,13 @@ - (AVMutableVideoComposition*)getVideoCompositionWithTransform:(CGAffineTransfor 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); + + float nominalFrameRate = videoTrack.nominalFrameRate; + int fps = 30; + if (nominalFrameRate > 0) { + fps = (int) ceil(nominalFrameRate); + } + videoComposition.frameDuration = CMTimeMake(1, fps); return videoComposition; } @@ -173,7 +177,6 @@ - (CGAffineTransform)fixTransform:(AVAssetTrack*)videoTrack { // 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 NSInteger rotationDegrees = (NSInteger)round(radiansToDegrees(atan2(transform.b, transform.a))); - //NSLog(@"VIDEO__ %f, %f, %f, %f, %li", transform.tx, transform.ty, videoTrack.naturalSize.height, videoTrack.naturalSize.width, (long)rotationDegrees); if (rotationDegrees == 90) { transform.tx = videoTrack.naturalSize.height; transform.ty = 0; @@ -187,29 +190,29 @@ - (CGAffineTransform)fixTransform:(AVAssetTrack*)videoTrack { return transform; } -- (void)setDataSourceAsset:(NSString*)asset withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl overriddenDuration:(int) overriddenDuration{ +- (void)setDataSourceAsset:(NSString*)asset withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl cacheKey:(NSString*)cacheKey cacheManager:(CacheManager*)cacheManager overriddenDuration:(int) overriddenDuration{ NSString* path = [[NSBundle mainBundle] pathForResource:asset ofType:nil]; - return [self setDataSourceURL:[NSURL fileURLWithPath:path] withKey:key withCertificateUrl:certificateUrl withLicenseUrl:licenseUrl withHeaders: @{} withCache: false overriddenDuration:overriddenDuration]; + return [self setDataSourceURL:[NSURL fileURLWithPath:path] withKey:key withCertificateUrl:certificateUrl withLicenseUrl:(NSString*)licenseUrl withHeaders: @{} withCache: false cacheKey:cacheKey cacheManager:cacheManager overriddenDuration:overriddenDuration]; } -- (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl withHeaders:(NSDictionary*)headers withCache:(BOOL)useCache overriddenDuration:(int) overriddenDuration{ +- (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withCertificateUrl:(NSString*)certificateUrl withLicenseUrl:(NSString*)licenseUrl withHeaders:(NSDictionary*)headers withCache:(BOOL)useCache cacheKey:(NSString*)cacheKey cacheManager:(CacheManager*)cacheManager overriddenDuration:(int) overriddenDuration{ _overriddenDuration = 0; - if (headers == [NSNull null]){ + if (headers == [NSNull null] || headers == NULL){ headers = @{}; } + AVPlayerItem* item; if (useCache){ - [KTVHTTPCache downloadSetAdditionalHeaders:headers]; - NSURL *proxyURL = [KTVHTTPCache proxyURLWithOriginalURL:url]; - item = [AVPlayerItem playerItemWithURL:proxyURL]; + if (cacheKey == [NSNull null]){ + cacheKey = nil; + } + item = [cacheManager getCachingPlayerItemForNormalPlayback:url cacheKey:cacheKey headers:headers]; } else { AVURLAsset* asset = [AVURLAsset URLAssetWithURL:url options:@{@"AVURLAssetHTTPHeaderFieldsKey" : headers}]; - if (certificateUrl && certificateUrl != [NSNull null] && [certificateUrl length] > 0) { NSURL * certificateNSURL = [[NSURL alloc] initWithString: certificateUrl]; NSURL * licenseNSURL = [[NSURL alloc] initWithString: licenseUrl]; - _loaderDelegate = [[BetterPlayerEzDrmAssetsLoaderDelegate alloc] init:certificateNSURL withLicenseURL:licenseNSURL]; dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_DEFAULT, -1); dispatch_queue_t streamQueue = dispatch_queue_create("streamQueue", qos); @@ -217,11 +220,10 @@ - (void)setDataSourceURL:(NSURL*)url withKey:(NSString*)key withCertificateUrl:( } item = [AVPlayerItem playerItemWithAsset:asset]; } - + if (@available(iOS 10.0, *) && overriddenDuration > 0) { _overriddenDuration = overriddenDuration; } - return [self setDataSourcePlayerItem:item withKey:key]; } @@ -231,7 +233,7 @@ - (void)setDataSourcePlayerItem:(AVPlayerItem*)item withKey:(NSString*)key{ _isStalledCheckStarted = false; _playerRate = 1; [_player replaceCurrentItemWithPlayerItem:item]; - + AVAsset* asset = [item asset]; void (^assetCompletionHandler)(void) = ^{ if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) { @@ -260,7 +262,7 @@ - (void)setDataSourcePlayerItem:(AVPlayerItem*)item withKey:(NSString*)key{ } } }; - + [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] completionHandler:assetCompletionHandler]; [self addObservers:item]; } @@ -289,7 +291,7 @@ -(void)startStalledCheck{ return; } [self performSelector:@selector(startStalledCheck) withObject:nil afterDelay:1]; - + } } @@ -305,14 +307,14 @@ - (NSTimeInterval) availableDuration } else { return 0; } - + } - (void)observeValueForKeyPath:(NSString*)path ofObject:(id)object change:(NSDictionary*)change context:(void*)context { - + if ([path isEqualToString:@"rate"]) { if (@available(iOS 10.0, *)) { if (_pipController.pictureInPictureActive == true){ @@ -344,7 +346,7 @@ - (void)observeValueForKeyPath:(NSString*)path [self handleStalled]; } } - + if (context == timeRangeContext) { if (_eventSink != nil) { NSMutableArray*>* values = [[NSMutableArray alloc] init]; @@ -358,7 +360,7 @@ - (void)observeValueForKeyPath:(NSString*)path end = endTime; } } - + [values addObject:@[ @(start), @(end) ]]; } _eventSink(@{@"event" : @"bufferingUpdate", @"values" : values, @"key" : _key}); @@ -367,14 +369,14 @@ - (void)observeValueForKeyPath:(NSString*)path 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" @@ -409,13 +411,12 @@ - (void)observeValueForKeyPath:(NSString*)path - (void)updatePlayingState { if (!_isInitialized || !_key) { - NSLog(@"not initalized and paused!!"); return; } if (!self._observersAdded){ [self addObservers:[_player currentItem]]; } - + if (_isPlaying) { if (@available(iOS 10.0, *)) { [_player playImmediatelyAtRate:1.0]; @@ -437,15 +438,15 @@ - (void)onReadyToPlay { 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; @@ -455,18 +456,18 @@ - (void)onReadyToPlay { 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); - + int64_t duration = [BetterPlayerTimeUtils FLTCMTimeToMillis:(_player.currentItem.asset.duration)]; if (_overriddenDuration > 0 && duration > _overriddenDuration){ _player.currentItem.forwardPlaybackEndTime = CMTimeMake(_overriddenDuration/1000, 1); } - + _isInitialized = true; [self updatePlayingState]; _eventSink(@{ @@ -509,7 +510,7 @@ - (int64_t)duration { if (!CMTIME_IS_INVALID(_player.currentItem.forwardPlaybackEndTime)) { time = [[_player currentItem] forwardPlaybackEndTime]; } - + return [BetterPlayerTimeUtils FLTCMTimeToMillis:(time)]; } @@ -519,7 +520,7 @@ - (void)seekTo:(int)location { if (wasPlaying){ [_player pause]; } - + [_player seekToTime:CMTimeMake(location, 1000) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero @@ -561,7 +562,7 @@ - (void)setSpeed:(double)speed result:(FlutterResult)result { details:nil]); } } - + if (_isPlaying){ _player.rate = _playerRate; } @@ -671,15 +672,15 @@ - (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureCo } - (void)pictureInPictureControllerWillStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController API_AVAILABLE(ios(9.0)){ - + } - (void)pictureInPictureControllerWillStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { - + } - (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController failedToStartPictureInPictureWithError:(NSError *)error { - + } - (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:(void (^)(BOOL))completionHandler { @@ -689,8 +690,8 @@ - (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPict - (void) setAudioTrack:(NSString*) name index:(int) index{ AVMediaSelectionGroup *audioSelectionGroup = [[[_player currentItem] asset] mediaSelectionGroupForMediaCharacteristic: AVMediaCharacteristicAudible]; NSArray* options = audioSelectionGroup.options; - - + + for (int audioTrackIndex = 0; audioTrackIndex < [options count]; audioTrackIndex++) { AVMediaSelectionOption* option = [options objectAtIndex:audioTrackIndex]; NSArray *metaDatas = [AVMetadataItem metadataItemsFromArray:option.commonMetadata withKey:@"title" keySpace:@"comn"]; @@ -700,9 +701,9 @@ - (void) setAudioTrack:(NSString*) name index:(int) index{ [[_player currentItem] selectMediaOption:option inMediaSelectionGroup: audioSelectionGroup]; } } - + } - + } - (void)setMixWithOthers:(bool)mixWithOthers { diff --git a/ios/Classes/BetterPlayerPlugin.h b/ios/Classes/BetterPlayerPlugin.h index d9722254c..2854edabe 100644 --- a/ios/Classes/BetterPlayerPlugin.h +++ b/ios/Classes/BetterPlayerPlugin.h @@ -6,7 +6,6 @@ #import #import #import -#import #import #import "BetterPlayerTimeUtils.h" #import "BetterPlayer.h" @@ -18,4 +17,4 @@ @property(readonly, strong, nonatomic) NSMutableDictionary* players; @property(readonly, strong, nonatomic) NSObject* registrar; -@end +@end \ No newline at end of file diff --git a/ios/Classes/BetterPlayerPlugin.m b/ios/Classes/BetterPlayerPlugin.m index 1c3fea89b..ae036a298 100644 --- a/ios/Classes/BetterPlayerPlugin.m +++ b/ios/Classes/BetterPlayerPlugin.m @@ -3,7 +3,7 @@ // found in the LICENSE file. #import "BetterPlayerPlugin.h" - +#import #if !__has_feature(objc_arc) #error Code Requires ARC. @@ -14,6 +14,7 @@ @implementation BetterPlayerPlugin NSMutableDictionary* _dataSourceDict; NSMutableDictionary* _timeObserverIdDict; NSMutableDictionary* _artworkImageDict; +CacheManager* _cacheManager; int texturesCount = -1; BetterPlayer* _notificationPlayer; bool _remoteCommandsInitialized = false; @@ -39,7 +40,8 @@ - (instancetype)initWithRegistrar:(NSObject*)registrar { _timeObserverIdDict = [NSMutableDictionary dictionary]; _artworkImageDict = [NSMutableDictionary dictionary]; _dataSourceDict = [NSMutableDictionary dictionary]; - [KTVHTTPCache proxyStart:nil]; + _cacheManager = [[CacheManager alloc] init]; + [_cacheManager setup]; return self; } @@ -95,7 +97,7 @@ - (void) setupRemoteNotification :(BetterPlayer*) player{ NSString* title = dataSource[@"title"]; NSString* author = dataSource[@"author"]; NSString* imageUrl = dataSource[@"imageUrl"]; - + if (showNotification){ [self setRemoteCommandsNotificationActive]; [self setupRemoteCommands: player]; @@ -113,7 +115,7 @@ - (void) setRemoteCommandsNotificationNotActive{ if ([_players count] == 0) { [[AVAudioSession sharedInstance] setActive:false error:nil]; } - + [[UIApplication sharedApplication] endReceivingRemoteControlEvents]; } @@ -131,35 +133,34 @@ - (void) setupRemoteCommands:(BetterPlayer*)player { if (@available(iOS 9.1, *)) { [commandCenter.changePlaybackPositionCommand setEnabled:YES]; } - + [commandCenter.togglePlayPauseCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { if (_notificationPlayer != [NSNull null]){ if (_notificationPlayer.isPlaying){ _notificationPlayer.eventSink(@{@"event" : @"play"}); } else { _notificationPlayer.eventSink(@{@"event" : @"pause"}); - } } return MPRemoteCommandHandlerStatusSuccess; }]; - + [commandCenter.playCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { if (_notificationPlayer != [NSNull null]){ _notificationPlayer.eventSink(@{@"event" : @"play"}); } return MPRemoteCommandHandlerStatusSuccess; }]; - + [commandCenter.pauseCommand addTargetWithHandler: ^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { if (_notificationPlayer != [NSNull null]){ _notificationPlayer.eventSink(@{@"event" : @"pause"}); } return MPRemoteCommandHandlerStatusSuccess; }]; - - - + + + if (@available(iOS 9.1, *)) { [commandCenter.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) { if (_notificationPlayer != [NSNull null]){ @@ -171,7 +172,6 @@ - (void) setupRemoteCommands:(BetterPlayer*)player { } return MPRemoteCommandHandlerStatusSuccess; }]; - } _remoteCommandsInitialized = true; } @@ -179,24 +179,24 @@ - (void) setupRemoteCommands:(BetterPlayer*)player { - (void) setupRemoteCommandNotification:(BetterPlayer*)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, ^{ @@ -217,7 +217,7 @@ - (void) setupRemoteCommandNotification:(BetterPlayer*)player, NSString* title, [MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nowPlayingInfoDict; } @catch(NSException *exception) { - + } }); } @@ -239,7 +239,7 @@ - (void) setupUpdateListener:(BetterPlayer*)player,NSString* title, NSString* au 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]; } @@ -267,25 +267,25 @@ - (void) stopOtherUpdateListener: (BetterPlayer*) player{ if (currentPlayerTextureId == textureId){ continue; } - + id timeObserverId = [_timeObserverIdDict objectForKey:textureId]; BetterPlayer* 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) { [_players[textureId] dispose]; } - + [_players removeAllObjects]; result(nil); } else if ([@"create" isEqualToString:call.method]) { @@ -298,7 +298,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { if ([@"setDataSource" isEqualToString:call.method]) { [player clear]; // This call will clear cached frame because we will return transparent frame - + NSDictionary* dataSource = argsMap[@"dataSource"]; [_dataSourceDict setObject:dataSource forKey:[self getTextureId:player]]; NSString* assetArg = dataSource[@"asset"]; @@ -307,21 +307,26 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSString* certificateUrl = dataSource[@"certificateUrl"]; NSString* licenseUrl = dataSource[@"licenseUrl"]; NSDictionary* headers = dataSource[@"headers"]; - + NSString* cacheKey = dataSource[@"cacheKey"]; + NSNumber* maxCacheSize = dataSource[@"maxCacheSize"]; 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 (useCache){ + [_cacheManager setMaxCacheSize:maxCacheSize]; + } } - - if (headers == nil){ + + if (headers == [NSNull null] || headers == NULL){ headers = @{}; } + if (assetArg) { NSString* assetPath; NSString* package = dataSource[@"package"]; @@ -330,9 +335,9 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else { assetPath = [_registrar lookupKeyForAsset:assetArg]; } - [player setDataSourceAsset:assetPath withKey:key withCertificateUrl:certificateUrl withLicenseUrl: licenseUrl overriddenDuration:overriddenDuration]; + [player setDataSourceAsset:assetPath withKey:key withCertificateUrl:certificateUrl withLicenseUrl: licenseUrl cacheKey:cacheKey cacheManager:_cacheManager overriddenDuration:overriddenDuration]; } else if (uriArg) { - [player setDataSourceURL:[NSURL URLWithString:uriArg] withKey:key withCertificateUrl:certificateUrl withLicenseUrl: licenseUrl withHeaders:headers withCache: useCache overriddenDuration:overriddenDuration]; + [player setDataSourceURL:[NSURL URLWithString:uriArg] withKey:key withCertificateUrl:certificateUrl withLicenseUrl: licenseUrl withHeaders:headers withCache: useCache cacheKey:cacheKey cacheManager:_cacheManager overriddenDuration:overriddenDuration]; } else { result(FlutterMethodNotImplemented); } @@ -388,7 +393,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { int width = [argsMap[@"width"] intValue]; int height = [argsMap[@"height"] intValue]; int bitrate = [argsMap[@"bitrate"] intValue]; - + [player setTrackParameters:width: height : bitrate]; result(nil); } else if ([@"enablePictureInPicture" isEqualToString:call.method]){ @@ -404,7 +409,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { return; } } - + result([NSNumber numberWithBool:false]); } else if ([@"disablePictureInPicture" isEqualToString:call.method]){ [player disablePictureInPicture]; @@ -415,8 +420,44 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [player setAudioTrack:name index: index]; } else if ([@"setMixWithOthers" isEqualToString:call.method]){ [player setMixWithOthers:[argsMap[@"mixWithOthers"] boolValue]]; + } else if ([@"preCache" isEqualToString:call.method]){ + NSDictionary* dataSource = argsMap[@"dataSource"]; + NSString* urlArg = dataSource[@"uri"]; + NSString* cacheKey = dataSource[@"cacheKey"]; + NSDictionary* headers = dataSource[@"headers"]; + NSNumber* maxCacheSize = dataSource[@"maxCacheSize"]; + if (headers == [ NSNull null ]){ + headers = @{}; + } + + if (urlArg != [NSNull null]){ + NSURL* url = [NSURL URLWithString:urlArg]; + if ([_cacheManager isPreCacheSupportedWithUrl:url]){ + [_cacheManager setMaxCacheSize:maxCacheSize]; + [_cacheManager preCacheURL:url cacheKey:cacheKey withHeaders:headers completionHandler:^(BOOL success){ + }]; + } else { + NSLog(@"Pre cache is not supported for given data source."); + } + } + result(nil); } else if ([@"clearCache" isEqualToString:call.method]){ - [KTVHTTPCache cacheDeleteAllCaches]; + [_cacheManager clearCache]; + result(nil); + } else if ([@"stopPreCache" isEqualToString:call.method]){ + NSString* urlArg = argsMap[@"url"]; + NSString* cacheKey = argsMap[@"cacheKey"]; + if (urlArg != [NSNull null]){ + NSURL* url = [NSURL URLWithString:urlArg]; + if ([_cacheManager isPreCacheSupportedWithUrl:url]){ + [_cacheManager stopPreCache:url cacheKey:cacheKey + completionHandler:^(BOOL success){ + }]; + } else { + NSLog(@"Stop pre cache is not supported for given data source."); + } + } + result(nil); } else { result(FlutterMethodNotImplemented); } diff --git a/ios/Classes/CacheManager.swift b/ios/Classes/CacheManager.swift new file mode 100644 index 000000000..31ee59364 --- /dev/null +++ b/ios/Classes/CacheManager.swift @@ -0,0 +1,222 @@ +import AVKit +import Cache +import HLSCachingReverseProxyServer +import GCDWebServer +import PINCache + +@objc public class CacheManager: NSObject { + + // We store the last pre-cached CachingPlayerItem objects to be able to play even if the download + // has not finished. + var _preCachedURLs = Dictionary() + + var completionHandler: ((_ success:Bool) -> Void)? = nil + + var diskConfig = DiskConfig(name: "BetterPlayerCache", expiry: .date(Date().addingTimeInterval(3600*24*30)), + maxSize: 100*1024*1024) + + // Flag whether the CachingPlayerItem was already cached. + var _existsInStorage: Bool = false + + let memoryConfig = MemoryConfig( + // Expiry date that will be applied by default for every added object + // if it's not overridden in the `setObject(forKey:expiry:)` method + expiry: .never, + // The maximum number of objects in memory the cache should hold + countLimit: 0, + // The maximum total cost that the cache can hold before it starts evicting objects, 0 for no limit + totalCostLimit: 0 + ) + + var server: HLSCachingReverseProxyServer? + + lazy var storage: Cache.Storage? = { + return try? Cache.Storage(diskConfig: diskConfig, memoryConfig: memoryConfig, transformer: TransformerFactory.forCodable(ofType: Data.self)) + }() + + ///Setups cache server for HLS streams + @objc public func setup(){ + GCDWebServer.setLogLevel(4) + let webServer = GCDWebServer() + let cache = PINCache.shared + let urlSession = URLSession.shared + server = HLSCachingReverseProxyServer(webServer: webServer, urlSession: urlSession, cache: cache) + server?.start(port: 8080) + } + + @objc public func setMaxCacheSize(_ maxCacheSize: NSNumber?){ + if let unsigned = maxCacheSize { + let _maxCacheSize = unsigned.uintValue + diskConfig = DiskConfig(name: "BetterPlayerCache", expiry: .date(Date().addingTimeInterval(3600*24*30)), maxSize: _maxCacheSize) + } + } + + // MARK: - Logic + @objc public func preCacheURL(_ url: URL, cacheKey: String?, withHeaders headers: Dictionary, completionHandler: ((_ success:Bool) -> Void)?) { + self.completionHandler = completionHandler + + let _key: String = cacheKey ?? url.absoluteString + // Make sure the item is not already being downloaded + if self._preCachedURLs[_key] == nil { + if let item = self.getCachingPlayerItem(url, cacheKey: _key, headers: headers){ + if !self._existsInStorage { + self._preCachedURLs[_key] = item + item.download() + } else { + self.completionHandler?(true) + } + } else { + self.completionHandler?(false) + } + } else { + self.completionHandler?(true) + } + } + + @objc public func stopPreCache(_ url: URL, cacheKey: String?, completionHandler: ((_ success:Bool) -> Void)?){ + let _key: String = cacheKey ?? url.absoluteString + if self._preCachedURLs[_key] != nil { + let playerItem = self._preCachedURLs[_key]! + playerItem.stopDownload() + self._preCachedURLs.removeValue(forKey: _key) + self.completionHandler?(true) + return + } + self.completionHandler?(false) + } + + ///Gets caching player item for normal playback. + @objc public func getCachingPlayerItemForNormalPlayback(_ url: URL, cacheKey: String?, headers: Dictionary) -> AVPlayerItem? { + let mimeTypeResult = getMimeType(url:url) + if (mimeTypeResult.1 == "application/vnd.apple.mpegurl"){ + let reverseProxyURL = server?.reverseProxyURL(from: url)! + let playerItem = AVPlayerItem(url: reverseProxyURL!) + return playerItem + } else { + return getCachingPlayerItem(url, cacheKey: cacheKey, headers: headers) + } + } + + + // Get a CachingPlayerItem either from the network if it's not cached or from the cache. + @objc public func getCachingPlayerItem(_ url: URL, cacheKey: String?, headers: Dictionary) -> CachingPlayerItem? { + let playerItem: CachingPlayerItem + let _key: String = cacheKey ?? url.absoluteString + // Fetch ongoing pre-cached url if it exists + if self._preCachedURLs[_key] != nil { + playerItem = self._preCachedURLs[_key]! + self._preCachedURLs.removeValue(forKey: _key) + } else { + // Trying to retrieve a track from cache syncronously + let data = try? storage?.object(forKey: _key) + if data != nil { + // The file is cached. + self._existsInStorage = true + let mimeTypeResult = getMimeType(url:url) + if (mimeTypeResult.1.isEmpty){ + NSLog("Cache error: couldn't find mime type for url: \(url.absoluteURL). For this URL cache didn't work and video will be played without cache.") + playerItem = CachingPlayerItem(url: url, cacheKey: _key, headers: headers) + } else { + playerItem = CachingPlayerItem(data: data!, mimeType: mimeTypeResult.1, fileExtension: mimeTypeResult.0) + } + } else { + // The file is not cached. + playerItem = CachingPlayerItem(url: url, cacheKey: _key, headers: headers) + self._existsInStorage = false + } + } + playerItem.delegate = self + return playerItem + } + + // Remove all objects + @objc public func clearCache(){ + try? storage?.removeAll() + self._preCachedURLs = Dictionary() + } + + private func getMimeType(url: URL) -> (String,String){ + let videoExtension = url.pathExtension + var mimeType = "" + switch (videoExtension){ + case "m3u": + mimeType = "application/vnd.apple.mpegurl" + case "m3u8": + mimeType = "application/vnd.apple.mpegurl" + case "3gp": + mimeType = "video/3gpp" + case "mp4": + mimeType = "video/mp4" + case "m4a": + mimeType = "video/mp4" + case "m4p": + mimeType = "video/mp4" + case "m4b": + mimeType = "video/mp4" + case "m4r": + mimeType = "video/mp4" + case "m4v": + mimeType = "video/mp4" + case "m1v": + mimeType = "video/mpeg" + case "mpg": + mimeType = "video/mpeg" + case "mp2": + mimeType = "video/mpeg" + case "mpeg": + mimeType = "video/mpeg" + case "mpe": + mimeType = "video/mpeg" + case "mpv": + mimeType = "video/mpeg" + case "ogg": + mimeType = "video/ogg" + case "mov": + mimeType = "video/quicktime" + case "qt": + mimeType = "video/quicktime" + case "webm": + mimeType = "video/webm" + case "asf": + mimeType = "video/ms-asf" + case "wma": + mimeType = "video/ms-asf" + case "wmv": + mimeType = "video/ms-asf" + case "avi": + mimeType = "video/x-msvideo" + default: + mimeType = "" + } + + return (videoExtension, mimeType) + } + + ///Checks wheter pre cache is supported for given url. + @objc public func isPreCacheSupported(url: URL) -> Bool{ + let mimeTypeResult = getMimeType(url:url) + return !mimeTypeResult.1.isEmpty && mimeTypeResult.1 != "application/vnd.apple.mpegurl" + } +} + +// MARK: - CachingPlayerItemDelegate +extension CacheManager: CachingPlayerItemDelegate { + func playerItem(_ playerItem: CachingPlayerItem, didFinishDownloadingData data: Data) { + // A track is downloaded. Saving it to the cache asynchronously. + storage?.async.setObject(data, forKey: playerItem.cacheKey ?? playerItem.url.absoluteString, completion: { _ in }) + self.completionHandler?(true) + } + + func playerItem(_ playerItem: CachingPlayerItem, didDownloadBytesSoFar bytesDownloaded: Int, outOf bytesExpected: Int){ + /// Is called every time a new portion of data is received. + let percentage = Double(bytesDownloaded)/Double(bytesExpected)*100.0 + let str = String(format: "%.1f%%", percentage) + //NSLog("Downloading... %@", str) + } + + func playerItem(_ playerItem: CachingPlayerItem, downloadingFailedWith error: Error){ + /// Is called on downloading error. + NSLog("Error when downloading the file %@", error as NSError); + self.completionHandler?(false) + } +} diff --git a/ios/Classes/CachingPlayerItem.swift b/ios/Classes/CachingPlayerItem.swift new file mode 100644 index 000000000..3d45e113a --- /dev/null +++ b/ios/Classes/CachingPlayerItem.swift @@ -0,0 +1,279 @@ +// Based on https://github.com/neekeetab/CachingPlayerItem. + +import Foundation +import AVFoundation + +fileprivate extension URL { + + func withScheme(_ scheme: String) -> URL? { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false) + components?.scheme = scheme + return components?.url + } + +} + +@objc protocol CachingPlayerItemDelegate { + + /// Is called when the media file is fully downloaded. + @objc optional func playerItem(_ playerItem: CachingPlayerItem, didFinishDownloadingData data: Data) + + /// Is called every time a new portion of data is received. + @objc optional func playerItem(_ playerItem: CachingPlayerItem, didDownloadBytesSoFar bytesDownloaded: Int, outOf bytesExpected: Int) + + /// Is called after initial prebuffering is finished, means + /// we are ready to play. + @objc optional func playerItemReadyToPlay(_ playerItem: CachingPlayerItem) + + /// Is called when the data being downloaded did not arrive in time to + /// continue playback. + @objc optional func playerItemPlaybackStalled(_ playerItem: CachingPlayerItem) + + /// Is called on downloading error. + @objc optional func playerItem(_ playerItem: CachingPlayerItem, downloadingFailedWith error: Error) + +} + +open class CachingPlayerItem: AVPlayerItem { + + class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate { + + var playingFromData = false + var mimeType: String? // is required when playing from Data + var session: URLSession? + var headers: Dictionary? + var mediaData: Data? + var response: URLResponse? + var pendingRequests = Set() + weak var owner: CachingPlayerItem? + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + if playingFromData { + // Nothing to load. + } else if session == nil { + // If we're playing from a url, we need to download the file. + // We start loading the file on first request only. + guard let initialUrl = owner?.url else { + fatalError("internal inconsistency") + } + startDataRequest(with: initialUrl) + } + pendingRequests.insert(loadingRequest) + processPendingRequests() + return true + } + + func startDataRequest(with url: URL) { + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + var request = URLRequest(url: url) + request.httpMethod = "GET" + if let unwrappedDict = self.headers { + for (_key, _value) in unwrappedDict.enumerated() { + request.setValue((_value as! String), forHTTPHeaderField: (_key as! String)) + } + } + session?.dataTask(with: request).resume() + } + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { + pendingRequests.remove(loadingRequest) + } + + // MARK: URLSession delegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + mediaData?.append(data) + processPendingRequests() + owner?.delegate?.playerItem?(owner!, didDownloadBytesSoFar: mediaData!.count, outOf: Int(dataTask.countOfBytesExpectedToReceive)) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + completionHandler(Foundation.URLSession.ResponseDisposition.allow) + mediaData = Data() + self.response = response + processPendingRequests() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let errorUnwrapped = error { + owner?.delegate?.playerItem?(owner!, downloadingFailedWith: errorUnwrapped) + return + } + processPendingRequests() + owner?.delegate?.playerItem?(owner!, didFinishDownloadingData: mediaData!) + } + + // MARK: - + + func processPendingRequests() { + + // get all fullfilled requests + let requestsFulfilled = Set(pendingRequests.compactMap { + self.fillInContentInformationRequest($0.contentInformationRequest) + if self.haveEnoughDataToFulfillRequest($0.dataRequest!) { + $0.finishLoading() + return $0 + } + return nil + }) + + // remove fulfilled requests from pending requests + _ = requestsFulfilled.map { self.pendingRequests.remove($0) } + + } + + func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) { + + // if we play from Data we make no url requests, therefore we have no responses, so we need to fill in contentInformationRequest manually + if playingFromData { + contentInformationRequest?.contentType = self.mimeType + contentInformationRequest?.contentLength = Int64(mediaData!.count) + contentInformationRequest?.isByteRangeAccessSupported = true + return + } + + guard let responseUnwrapped = response else { + // have no response from the server yet + return + } + + contentInformationRequest?.contentType = responseUnwrapped.mimeType + contentInformationRequest?.contentLength = responseUnwrapped.expectedContentLength + contentInformationRequest?.isByteRangeAccessSupported = true + + } + + func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool { + + let requestedOffset = Int(dataRequest.requestedOffset) + let requestedLength = dataRequest.requestedLength + let currentOffset = Int(dataRequest.currentOffset) + + guard let songDataUnwrapped = mediaData, + songDataUnwrapped.count > currentOffset else { + // Don't have any data at all for this request. + return false + } + + let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength) + let dataToRespond = songDataUnwrapped.subdata(in: Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))) + dataRequest.respond(with: dataToRespond) + + return songDataUnwrapped.count >= requestedLength + requestedOffset + + } + + deinit { + session?.invalidateAndCancel() + } + + } + + fileprivate let resourceLoaderDelegate = ResourceLoaderDelegate() + let url: URL + var cacheKey: String? = nil + fileprivate let initialScheme: String? + fileprivate var customFileExtension: String? + + weak var delegate: CachingPlayerItemDelegate? + + ///Starts current download. + open func download() { + if resourceLoaderDelegate.session == nil { + resourceLoaderDelegate.startDataRequest(with: url) + } + } + ///Stops current download. + open func stopDownload(){ + resourceLoaderDelegate.session?.invalidateAndCancel() + } + + private let cachingPlayerItemScheme = "cachingPlayerItemScheme" + + /// Is used for playing remote files. + convenience init(url: URL, cacheKey: String?, headers: Dictionary) { + self.init(url: url, customFileExtension: nil, cacheKey: cacheKey, headers: headers) + } + + /// Override/append custom file extension to URL path. + /// This is required for the player to work correctly with the intended file type. + init(url: URL, customFileExtension: String?, cacheKey: String?, headers: Dictionary) { + self.cacheKey = cacheKey + self.url = url + self.resourceLoaderDelegate.headers = headers + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme, + var urlWithCustomScheme = url.withScheme(cachingPlayerItemScheme) else { + fatalError("Urls without a scheme are not supported") + } + self.initialScheme = scheme + + if let ext = customFileExtension { + urlWithCustomScheme.deletePathExtension() + urlWithCustomScheme.appendPathExtension(ext) + self.customFileExtension = ext + } + + let asset = AVURLAsset(url: urlWithCustomScheme) + asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) + super.init(asset: asset, automaticallyLoadedAssetKeys: nil) + + resourceLoaderDelegate.owner = self + + addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(playbackStalledHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self) + } + + /// Is used for playing from Data. + init(data: Data, mimeType: String, fileExtension: String) { + + guard let fakeUrl = URL(string: cachingPlayerItemScheme + "://whatever/file.\(fileExtension)") else { + fatalError("internal inconsistency") + } + + self.url = fakeUrl + self.initialScheme = nil + + resourceLoaderDelegate.mediaData = data + resourceLoaderDelegate.playingFromData = true + resourceLoaderDelegate.mimeType = mimeType + + let asset = AVURLAsset(url: fakeUrl) + asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main) + super.init(asset: asset, automaticallyLoadedAssetKeys: nil) + resourceLoaderDelegate.owner = self + + addObserver(self, forKeyPath: "status", options: NSKeyValueObservingOptions.new, context: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(playbackStalledHandler), name:NSNotification.Name.AVPlayerItemPlaybackStalled, object: self) + } + + // MARK: KVO + + override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + delegate?.playerItemReadyToPlay?(self) + } + + // MARK: Notification hanlers + + @objc func playbackStalledHandler() { + delegate?.playerItemPlaybackStalled?(self) + } + + // MARK: - + + override init(asset: AVAsset, automaticallyLoadedAssetKeys: [String]?) { + fatalError("not implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + removeObserver(self, forKeyPath: "status") + resourceLoaderDelegate.session?.invalidateAndCancel() + } + +} diff --git a/ios/better_player.podspec b/ios/better_player.podspec index e7aa40f60..2588cf0d1 100644 --- a/ios/better_player.podspec +++ b/ios/better_player.podspec @@ -15,8 +15,8 @@ A new flutter plugin project. s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - # KTVHTTPCache - s.dependency 'KTVHTTPCache', '~> 2.0.0' + s.dependency 'Cache' + s.dependency 'HLSCachingReverseProxyServer' s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } diff --git a/lib/src/asms/better_player_asms_subtitle.dart b/lib/src/asms/better_player_asms_subtitle.dart index 83a9d201b..1967edcd8 100644 --- a/lib/src/asms/better_player_asms_subtitle.dart +++ b/lib/src/asms/better_player_asms_subtitle.dart @@ -30,6 +30,9 @@ class BetterPlayerAsmsSubtitle { ///List of subtitle segments. Only used when [isSegmented] is true. final List? segments; + ///If the subtitle is the default + final bool? isDefault; + BetterPlayerAsmsSubtitle({ this.language, this.name, @@ -40,5 +43,6 @@ class BetterPlayerAsmsSubtitle { this.isSegmented, this.segmentsTime, this.segments, + this.isDefault, }); } diff --git a/lib/src/configuration/better_player_configuration.dart b/lib/src/configuration/better_player_configuration.dart index 45a23db4d..fc958210f 100644 --- a/lib/src/configuration/better_player_configuration.dart +++ b/lib/src/configuration/better_player_configuration.dart @@ -112,42 +112,46 @@ class BetterPlayerConfiguration { ///Default value is true. final bool autoDispose; - const BetterPlayerConfiguration({ - this.aspectRatio, - this.autoPlay = false, - this.startAt, - this.looping = false, - this.fullScreenByDefault = false, - this.placeholder, - this.showPlaceholderUntilPlay = false, - this.placeholderOnTop = true, - this.overlay, - this.errorBuilder, - this.allowedScreenSleep = true, - this.fullScreenAspectRatio, - this.deviceOrientationsOnFullScreen = const [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ], - this.systemOverlaysAfterFullScreen = SystemUiOverlay.values, - this.deviceOrientationsAfterFullScreen = const [ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ], - this.routePageBuilder, - this.eventListener, - this.subtitlesConfiguration = const BetterPlayerSubtitlesConfiguration(), - this.controlsConfiguration = const BetterPlayerControlsConfiguration(), - this.fit = BoxFit.fill, - this.rotation = 0, - this.playerVisibilityChangedBehavior, - this.translations, - this.autoDetectFullscreenDeviceOrientation = false, - this.handleLifecycle = true, - this.autoDispose = true, - }); + ///Flag which causes to player expand to fill all remaining space. Set to false + ///to use minimum constraints + final bool expandToFill; + + const BetterPlayerConfiguration( + {this.aspectRatio, + this.autoPlay = false, + this.startAt, + this.looping = false, + this.fullScreenByDefault = false, + this.placeholder, + this.showPlaceholderUntilPlay = false, + this.placeholderOnTop = true, + this.overlay, + this.errorBuilder, + this.allowedScreenSleep = true, + this.fullScreenAspectRatio, + this.deviceOrientationsOnFullScreen = const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ], + this.systemOverlaysAfterFullScreen = SystemUiOverlay.values, + this.deviceOrientationsAfterFullScreen = const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ], + this.routePageBuilder, + this.eventListener, + this.subtitlesConfiguration = const BetterPlayerSubtitlesConfiguration(), + this.controlsConfiguration = const BetterPlayerControlsConfiguration(), + this.fit = BoxFit.fill, + this.rotation = 0, + this.playerVisibilityChangedBehavior, + this.translations, + this.autoDetectFullscreenDeviceOrientation = false, + this.handleLifecycle = true, + this.autoDispose = true, + this.expandToFill = true}); BetterPlayerConfiguration copyWith({ double? aspectRatio, diff --git a/lib/src/configuration/better_player_controls_configuration.dart b/lib/src/configuration/better_player_controls_configuration.dart index 892b2f3cf..85d923322 100644 --- a/lib/src/configuration/better_player_controls_configuration.dart +++ b/lib/src/configuration/better_player_controls_configuration.dart @@ -168,12 +168,6 @@ class BetterPlayerControlsConfiguration { ///Color of text in bottom modal sheet used for overflow menu items. final Color overflowModalTextColor; - ///Quality of Gaussian Blur for x (iOS only). - final double sigmaX; - - ///Quality of Gaussian Blur for y (iOS only). - final double sigmaY; - const BetterPlayerControlsConfiguration({ this.controlBarColor = Colors.black87, this.textColor = Colors.white, @@ -226,8 +220,6 @@ class BetterPlayerControlsConfiguration { this.backgroundColor = Colors.black, this.overflowModalColor = Colors.white, this.overflowModalTextColor = Colors.black, - this.sigmaX = 10.0, - this.sigmaY = 10.0, }); factory BetterPlayerControlsConfiguration.white() { @@ -251,4 +243,12 @@ class BetterPlayerControlsConfiguration { skipForwardIcon: CupertinoIcons.goforward_15, ); } + + ///Setup BetterPlayerControlsConfiguration based on Theme options. + factory BetterPlayerControlsConfiguration.theme(ThemeData theme) { + return BetterPlayerControlsConfiguration( + textColor: theme.textTheme.bodyText1?.color ?? Colors.white, + iconsColor: theme.textTheme.button?.color ?? Colors.white, + ); + } } diff --git a/lib/src/controls/better_player_controls_state.dart b/lib/src/controls/better_player_controls_state.dart index 3b393d852..55434d57f 100644 --- a/lib/src/controls/better_player_controls_state.dart +++ b/lib/src/controls/better_player_controls_state.dart @@ -38,26 +38,30 @@ abstract class BetterPlayerControlsState } void skipBack() { - cancelAndRestartTimer(); - final beginning = const Duration().inMilliseconds; - final skip = (latestValue!.position - - Duration( - milliseconds: betterPlayerControlsConfiguration - .backwardSkipTimeInMilliseconds)) - .inMilliseconds; - betterPlayerController! - .seekTo(Duration(milliseconds: max(skip, beginning))); + if (latestValue != null) { + cancelAndRestartTimer(); + final beginning = const Duration().inMilliseconds; + final skip = (latestValue!.position - + Duration( + milliseconds: betterPlayerControlsConfiguration + .backwardSkipTimeInMilliseconds)) + .inMilliseconds; + betterPlayerController! + .seekTo(Duration(milliseconds: max(skip, beginning))); + } } void skipForward() { - cancelAndRestartTimer(); - final end = latestValue!.duration!.inMilliseconds; - final skip = (latestValue!.position + - Duration( - milliseconds: betterPlayerControlsConfiguration - .forwardSkipTimeInMilliseconds)) - .inMilliseconds; - betterPlayerController!.seekTo(Duration(milliseconds: min(skip, end))); + if (latestValue != null) { + cancelAndRestartTimer(); + final end = latestValue!.duration!.inMilliseconds; + final skip = (latestValue!.position + + Duration( + milliseconds: betterPlayerControlsConfiguration + .forwardSkipTimeInMilliseconds)) + .inMilliseconds; + betterPlayerController!.seekTo(Duration(milliseconds: min(skip, end))); + } } void onShowMoreClicked() { diff --git a/lib/src/controls/better_player_cupertino_controls.dart b/lib/src/controls/better_player_cupertino_controls.dart index 2d02082a7..c00186e48 100644 --- a/lib/src/controls/better_player_cupertino_controls.dart +++ b/lib/src/controls/better_player_cupertino_controls.dart @@ -1,6 +1,5 @@ // Dart imports: import 'dart:async'; -import 'dart:ui' as ui; // Flutter imports: import 'package:better_player/src/configuration/better_player_controls_configuration.dart'; @@ -79,17 +78,15 @@ class _BetterPlayerCupertinoControlsState ); } - final backgroundColor = _controlsConfiguration.controlBarColor; - final iconColor = _controlsConfiguration.iconsColor; _betterPlayerController = BetterPlayerController.of(context); _controller = _betterPlayerController!.videoPlayerController; + final backgroundColor = _controlsConfiguration.controlBarColor; + final iconColor = _controlsConfiguration.iconsColor; final orientation = MediaQuery.of(context).orientation; final barHeight = orientation == Orientation.portrait ? _controlsConfiguration.controlBarHeight : _controlsConfiguration.controlBarHeight + 10; const buttonPadding = 10.0; - final sigmaX = _controlsConfiguration.sigmaX; - final sigmaY = _controlsConfiguration.sigmaY; _wasLoading = isLoading(_latestValue); return GestureDetector( @@ -124,8 +121,6 @@ class _BetterPlayerCupertinoControlsState iconColor, barHeight, buttonPadding, - sigmaX, - sigmaY, ), if (_wasLoading) Expanded(child: Center(child: _buildLoadingWidget())) @@ -136,8 +131,6 @@ class _BetterPlayerCupertinoControlsState backgroundColor, iconColor, barHeight, - sigmaX, - sigmaY, ), ], ), @@ -177,8 +170,6 @@ class _BetterPlayerCupertinoControlsState Color backgroundColor, Color iconColor, double barHeight, - double sigmaX, - double sigmaY, ) { if (!betterPlayerController!.controlsEnabled) { return const SizedBox(); @@ -188,63 +179,56 @@ class _BetterPlayerCupertinoControlsState duration: _controlsConfiguration.controlsHideTime, onEnd: _onPlayerHide, child: Container( - color: Colors.transparent, alignment: Alignment.bottomCenter, margin: EdgeInsets.all(marginSize), child: ClipRRect( - borderRadius: BorderRadius.circular(10.0), - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: sigmaX, - sigmaY: sigmaY, - ), - child: Container( - height: barHeight, - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.5), - ), - child: _betterPlayerController!.isLiveStream() - ? Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 8), - if (_controlsConfiguration.enablePlayPause) - _buildPlayPause(_controller!, iconColor, barHeight) - else - const SizedBox(), - const SizedBox(width: 8), - _buildLiveWidget(), - ], - ) - : Row( - children: [ - if (_controlsConfiguration.enableSkips) - _buildSkipBack(iconColor, barHeight) - else - const SizedBox(), - if (_controlsConfiguration.enablePlayPause) - _buildPlayPause(_controller!, iconColor, barHeight) - else - const SizedBox(), - if (_controlsConfiguration.enableSkips) - _buildSkipForward(iconColor, barHeight) - else - const SizedBox(), - if (_controlsConfiguration.enableProgressText) - _buildPosition() - else - const SizedBox(), - if (_controlsConfiguration.enableProgressBar) - _buildProgressBar() - else - const SizedBox(), - if (_controlsConfiguration.enableProgressText) - _buildRemaining() - else - const SizedBox() - ], - ), + borderRadius: BorderRadius.circular(10), + child: Container( + height: barHeight, + decoration: BoxDecoration( + color: backgroundColor, ), + child: _betterPlayerController!.isLiveStream() + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 8), + if (_controlsConfiguration.enablePlayPause) + _buildPlayPause(_controller!, iconColor, barHeight) + else + const SizedBox(), + const SizedBox(width: 8), + _buildLiveWidget(), + ], + ) + : Row( + children: [ + if (_controlsConfiguration.enableSkips) + _buildSkipBack(iconColor, barHeight) + else + const SizedBox(), + if (_controlsConfiguration.enablePlayPause) + _buildPlayPause(_controller!, iconColor, barHeight) + else + const SizedBox(), + if (_controlsConfiguration.enableSkips) + _buildSkipForward(iconColor, barHeight) + else + const SizedBox(), + if (_controlsConfiguration.enableProgressText) + _buildPosition() + else + const SizedBox(), + if (_controlsConfiguration.enableProgressBar) + _buildProgressBar() + else + const SizedBox(), + if (_controlsConfiguration.enableProgressText) + _buildRemaining() + else + const SizedBox() + ], + ), ), ), ), @@ -268,8 +252,6 @@ class _BetterPlayerCupertinoControlsState double barHeight, double iconSize, double buttonPadding, - double sigmaX, - double sigmaY, ) { return GestureDetector( onTap: _onExpandCollapse, @@ -278,24 +260,19 @@ class _BetterPlayerCupertinoControlsState duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: sigmaX, sigmaY: sigmaY), - child: Container( - height: barHeight, - padding: EdgeInsets.symmetric( - horizontal: buttonPadding, - ), - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.5), - ), - child: Center( - child: Icon( - _betterPlayerController!.isFullScreen - ? _controlsConfiguration.fullscreenDisableIcon - : _controlsConfiguration.fullscreenEnableIcon, - color: iconColor, - size: iconSize, - ), + child: Container( + height: barHeight, + padding: EdgeInsets.symmetric( + horizontal: buttonPadding, + ), + decoration: BoxDecoration(color: backgroundColor), + child: Center( + child: Icon( + _betterPlayerController!.isFullScreen + ? _controlsConfiguration.fullscreenDisableIcon + : _controlsConfiguration.fullscreenEnableIcon, + color: iconColor, + size: iconSize, ), ), ), @@ -340,7 +317,6 @@ class _BetterPlayerCupertinoControlsState double barHeight, double iconSize, double buttonPadding, - double sigmaX, ) { return GestureDetector( onTap: () { @@ -351,22 +327,19 @@ class _BetterPlayerCupertinoControlsState duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: sigmaX), + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + ), child: Container( - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.5), + height: barHeight, + padding: EdgeInsets.symmetric( + horizontal: buttonPadding, ), - child: Container( - height: barHeight, - padding: EdgeInsets.symmetric( - horizontal: buttonPadding, - ), - child: Icon( - _controlsConfiguration.overflowMenuIcon, - color: iconColor, - size: iconSize, - ), + child: Icon( + _controlsConfiguration.overflowMenuIcon, + color: iconColor, + size: iconSize, ), ), ), @@ -382,7 +355,6 @@ class _BetterPlayerCupertinoControlsState double barHeight, double iconSize, double buttonPadding, - double sigmaX, ) { return GestureDetector( onTap: () { @@ -400,24 +372,21 @@ class _BetterPlayerCupertinoControlsState duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: sigmaX), + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + ), child: Container( - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.5), + height: barHeight, + padding: EdgeInsets.symmetric( + horizontal: buttonPadding, ), - child: Container( - height: barHeight, - padding: EdgeInsets.symmetric( - horizontal: buttonPadding, - ), - child: Icon( - (_latestValue != null && _latestValue!.volume > 0) - ? _controlsConfiguration.muteIcon - : _controlsConfiguration.unMuteIcon, - color: iconColor, - size: iconSize, - ), + child: Icon( + (_latestValue != null && _latestValue!.volume > 0) + ? _controlsConfiguration.muteIcon + : _controlsConfiguration.unMuteIcon, + color: iconColor, + size: iconSize, ), ), ), @@ -520,8 +489,6 @@ class _BetterPlayerCupertinoControlsState Color iconColor, double topBarHeight, double buttonPadding, - double sigmaX, - double sigmaY, ) { if (!betterPlayerController!.controlsEnabled) { return const SizedBox(); @@ -544,8 +511,6 @@ class _BetterPlayerCupertinoControlsState barHeight, iconSize, buttonPadding, - sigmaX, - sigmaY, ) else const SizedBox(), @@ -559,7 +524,6 @@ class _BetterPlayerCupertinoControlsState barHeight, iconSize, buttonPadding, - sigmaX, ) else const SizedBox(), @@ -572,7 +536,6 @@ class _BetterPlayerCupertinoControlsState barHeight, iconSize, buttonPadding, - sigmaX, ) else const SizedBox(), @@ -587,7 +550,6 @@ class _BetterPlayerCupertinoControlsState barHeight, iconSize, buttonPadding, - sigmaX, ) else const SizedBox(), @@ -598,7 +560,7 @@ class _BetterPlayerCupertinoControlsState Widget _buildNextVideoWidget() { return StreamBuilder( - stream: _betterPlayerController!.nextVideoTimeStreamController.stream, + stream: _betterPlayerController!.nextVideoTimeStream, builder: (context, snapshot) { final time = snapshot.data; if (time != null && time > 0) { @@ -818,50 +780,53 @@ class _BetterPlayerCupertinoControlsState ); } - Widget _buildPipButton(Color backgroundColor, Color iconColor, - double barHeight, double iconSize, double buttonPadding, double sigmaX) { + Widget _buildPipButton( + Color backgroundColor, + Color iconColor, + double barHeight, + double iconSize, + double buttonPadding, + ) { return FutureBuilder( - future: _betterPlayerController!.isPictureInPictureSupported(), - builder: (context, snapshot) { - final isPipSupported = snapshot.data ?? false; - if (isPipSupported && - _betterPlayerController!.betterPlayerGlobalKey != null) { - return GestureDetector( - onTap: () { - betterPlayerController!.enablePictureInPicture( - betterPlayerController!.betterPlayerGlobalKey!); - }, - child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, - duration: _controlsConfiguration.controlsHideTime, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: sigmaX), - child: Container( - height: barHeight, - padding: EdgeInsets.only( - left: buttonPadding, - right: buttonPadding, - ), - decoration: BoxDecoration( - color: backgroundColor.withOpacity(0.5), - ), - child: Center( - child: Icon( - _controlsConfiguration.pipMenuIcon, - color: iconColor, - size: iconSize, - ), - ), + future: _betterPlayerController!.isPictureInPictureSupported(), + builder: (context, snapshot) { + final isPipSupported = snapshot.data ?? false; + if (isPipSupported && + _betterPlayerController!.betterPlayerGlobalKey != null) { + return GestureDetector( + onTap: () { + betterPlayerController!.enablePictureInPicture( + betterPlayerController!.betterPlayerGlobalKey!); + }, + child: AnimatedOpacity( + opacity: _hideStuff ? 0.0 : 1.0, + duration: _controlsConfiguration.controlsHideTime, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + height: barHeight, + padding: EdgeInsets.only( + left: buttonPadding, + right: buttonPadding, + ), + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.5), + ), + child: Center( + child: Icon( + _controlsConfiguration.pipMenuIcon, + color: iconColor, + size: iconSize, ), ), ), ), - ); - } else { - return const SizedBox(); - } - }); + ), + ); + } else { + return const SizedBox(); + } + }, + ); } } diff --git a/lib/src/controls/better_player_material_controls.dart b/lib/src/controls/better_player_material_controls.dart index 2f8493e6b..0000ab5f0 100644 --- a/lib/src/controls/better_player_material_controls.dart +++ b/lib/src/controls/better_player_material_controls.dart @@ -384,20 +384,22 @@ class _BetterPlayerMaterialControlsState color: Colors.black54, //_controlsConfiguration.controlBarColor, width: double.infinity, height: double.infinity, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (_controlsConfiguration.enableSkips) - _buildSkipButton() - else - const SizedBox(), - _buildReplayButton(_controller!), - if (_controlsConfiguration.enableSkips) - _buildForwardButton() - else - const SizedBox(), - ], - ), + child: _betterPlayerController?.isLiveStream() == true + ? const SizedBox() + : Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (_controlsConfiguration.enableSkips) + _buildSkipButton() + else + const SizedBox(), + _buildReplayButton(_controller!), + if (_controlsConfiguration.enableSkips) + _buildForwardButton() + else + const SizedBox(), + ], + ), ); } @@ -489,7 +491,7 @@ class _BetterPlayerMaterialControlsState Widget _buildNextVideoWidget() { return StreamBuilder( - stream: _betterPlayerController!.nextVideoTimeStreamController.stream, + stream: _betterPlayerController!.nextVideoTimeStream, builder: (context, snapshot) { final time = snapshot.data; if (time != null && time > 0) { @@ -558,6 +560,7 @@ class _BetterPlayerMaterialControlsState Widget _buildPlayPause(VideoPlayerController controller) { return BetterPlayerMaterialClickableWidget( + key: const Key("better_player_material_controls_play_pause_button"), onTap: _onPlayPause, child: Container( height: double.infinity, @@ -595,9 +598,9 @@ class _BetterPlayerMaterialControlsState children: [ TextSpan( text: ' / ${BetterPlayerUtils.formatDuration(duration)}', - style: const TextStyle( + style: TextStyle( fontSize: 10.0, - color: Colors.grey, + color: _controlsConfiguration.textColor, decoration: TextDecoration.none, ), ) @@ -706,7 +709,8 @@ class _BetterPlayerMaterialControlsState isLoading(_controller!.value)) { setState(() { _latestValue = _controller!.value; - if (isVideoFinished(_latestValue)) { + if (isVideoFinished(_latestValue) && + _betterPlayerController?.isLiveStream() == false) { _hideStuff = false; } }); @@ -716,27 +720,28 @@ class _BetterPlayerMaterialControlsState Widget _buildProgressBar() { return Expanded( - flex: 40, - child: Container( - alignment: Alignment.bottomCenter, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: BetterPlayerMaterialVideoProgressBar( - _controller, - _betterPlayerController, - onDragStart: () { - _hideTimer?.cancel(); - }, - onDragEnd: () { - _startHideTimer(); - }, - colors: BetterPlayerProgressColors( - playedColor: _controlsConfiguration.progressBarPlayedColor, - handleColor: _controlsConfiguration.progressBarHandleColor, - bufferedColor: _controlsConfiguration.progressBarBufferedColor, - backgroundColor: - _controlsConfiguration.progressBarBackgroundColor), - ), - )); + flex: 40, + child: Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: BetterPlayerMaterialVideoProgressBar( + _controller, + _betterPlayerController, + onDragStart: () { + _hideTimer?.cancel(); + }, + onDragEnd: () { + _startHideTimer(); + }, + colors: BetterPlayerProgressColors( + playedColor: _controlsConfiguration.progressBarPlayedColor, + handleColor: _controlsConfiguration.progressBarHandleColor, + bufferedColor: _controlsConfiguration.progressBarBufferedColor, + backgroundColor: + _controlsConfiguration.progressBarBackgroundColor), + ), + ), + ); } void _onPlayerHide() { diff --git a/lib/src/core/better_player.dart b/lib/src/core/better_player.dart index 37cff2f05..e7c31fa00 100644 --- a/lib/src/core/better_player.dart +++ b/lib/src/core/better_player.dart @@ -6,10 +6,12 @@ import 'package:better_player/better_player.dart'; import 'package:better_player/src/configuration/better_player_controller_event.dart'; import 'package:better_player/src/core/better_player_utils.dart'; import 'package:better_player/src/core/better_player_with_controls.dart'; + // Flutter imports: import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; + // Package imports: import 'package:visibility_detector/visibility_detector.dart'; import 'package:wakelock/wakelock.dart'; @@ -112,8 +114,8 @@ class _BetterPlayerState extends State if (_isFullScreen) { Wakelock.disable(); _navigatorState.maybePop(); - SystemChrome.setEnabledSystemUIOverlays( - _betterPlayerConfiguration.systemOverlaysAfterFullScreen); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: _betterPlayerConfiguration.systemOverlaysAfterFullScreen); SystemChrome.setPreferredOrientations( _betterPlayerConfiguration.deviceOrientationsAfterFullScreen); } @@ -225,7 +227,7 @@ class _BetterPlayerState extends State pageBuilder: _fullScreenRoutePageBuilder, ); - await SystemChrome.setEnabledSystemUIOverlays([]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); if (_betterPlayerConfiguration.autoDetectFullscreenDeviceOrientation == true) { @@ -263,8 +265,8 @@ class _BetterPlayerState extends State // so we do not need to check Wakelock.isEnabled. Wakelock.disable(); - await SystemChrome.setEnabledSystemUIOverlays( - _betterPlayerConfiguration.systemOverlaysAfterFullScreen); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: _betterPlayerConfiguration.systemOverlaysAfterFullScreen); await SystemChrome.setPreferredOrientations( _betterPlayerConfiguration.deviceOrientationsAfterFullScreen); } diff --git a/lib/src/core/better_player_controller.dart b/lib/src/core/better_player_controller.dart index 1528f8aaf..6bb7e0c65 100644 --- a/lib/src/core/better_player_controller.dart +++ b/lib/src/core/better_player_controller.dart @@ -62,7 +62,8 @@ class BetterPlayerController { VideoPlayerController? videoPlayerController; ///Expose all active eventListeners - List get eventListeners => _eventListeners; + List get eventListeners => + _eventListeners.sublist(1); /// Defines a event listener where video player events will be send. Function(BetterPlayerEvent)? get eventListener => @@ -118,9 +119,11 @@ class BetterPlayerController { int? _nextVideoTime; ///Stream controller which emits next video time. - StreamController nextVideoTimeStreamController = + final StreamController _nextVideoTimeStreamController = StreamController.broadcast(); + Stream get nextVideoTimeStream => _nextVideoTimeStreamController.stream; + ///Has player been disposed. bool _disposed = false; @@ -329,6 +332,7 @@ class BetterPlayerController { asmsIsSegmented: asmsSubtitle.isSegmented, asmsSegmentsTime: asmsSubtitle.segmentsTime, asmsSegments: asmsSubtitle.segments, + selectedByDefault: asmsSubtitle.isDefault, ), ); }); @@ -671,23 +675,28 @@ class BetterPlayerController { ///Set volume of player. Allows values from 0.0 to 1.0. Future setVolume(double volume) async { if (volume < 0.0 || volume > 1.0) { + BetterPlayerUtils.log("Volume must be between 0.0 and 1.0"); throw ArgumentError("Volume must be between 0.0 and 1.0"); } if (videoPlayerController == null) { BetterPlayerUtils.log("The data source has not been initialized"); - return; + throw StateError("The data source has not been initialized"); } await videoPlayerController!.setVolume(volume); - _postEvent(BetterPlayerEvent(BetterPlayerEventType.setVolume, - parameters: {_volumeParameter: volume})); + _postEvent(BetterPlayerEvent( + BetterPlayerEventType.setVolume, + parameters: {_volumeParameter: volume}, + )); } ///Set playback speed of video. Allows to set speed value between 0 and 2. Future setSpeed(double speed) async { - if (speed < 0 || speed > 2) { + if (speed <= 0 || speed > 2) { + BetterPlayerUtils.log("Speed must be between 0 and 2"); throw ArgumentError("Speed must be between 0 and 2"); } if (videoPlayerController == null) { + BetterPlayerUtils.log("The data source has not been initialized"); throw StateError("The data source has not been initialized"); } await videoPlayerController?.setSpeed(speed); @@ -821,6 +830,7 @@ class BetterPlayerController { ///Flag which determines whenever player is playing live data source. bool isLiveStream() { if (_betterPlayerDataSource == null) { + BetterPlayerUtils.log("The data source has not been initialized"); throw StateError("The data source has not been initialized"); } return _betterPlayerDataSource!.liveStream == true; @@ -829,6 +839,7 @@ class BetterPlayerController { ///Flag which determines whenever player data source has been initialized. bool? isVideoInitialized() { if (videoPlayerController == null) { + BetterPlayerUtils.log("The data source has not been initialized"); throw StateError("The data source has not been initialized"); } return videoPlayerController?.value.initialized; @@ -838,9 +849,16 @@ class BetterPlayerController { ///manually. void startNextVideoTimer() { if (_nextVideoTimer == null) { + if (betterPlayerPlaylistConfiguration == null) { + BetterPlayerUtils.log( + "BettterPlayerPlaylistConifugration has not been set!"); + throw StateError( + "BettterPlayerPlaylistConifugration has not been set!"); + } + _nextVideoTime = betterPlayerPlaylistConfiguration!.nextVideoDelay.inSeconds; - nextVideoTimeStreamController.add(_nextVideoTime); + _nextVideoTimeStreamController.add(_nextVideoTime); if (_nextVideoTime == 0) { return; } @@ -854,7 +872,7 @@ class BetterPlayerController { if (_nextVideoTime != null) { _nextVideoTime = _nextVideoTime! - 1; } - nextVideoTimeStreamController.add(_nextVideoTime); + _nextVideoTimeStreamController.add(_nextVideoTime); }); } } @@ -862,7 +880,7 @@ class BetterPlayerController { ///Cancel next video timer. Used in playlist. Do not use manually. void cancelNextVideoTimer() { _nextVideoTime = null; - nextVideoTimeStreamController.add(_nextVideoTime); + _nextVideoTimeStreamController.add(_nextVideoTime); _nextVideoTimer?.cancel(); _nextVideoTimer = null; } @@ -870,7 +888,7 @@ class BetterPlayerController { ///Play next video form playlist. Do not use manually. void playNextVideo() { _nextVideoTime = 0; - nextVideoTimeStreamController.add(_nextVideoTime); + _nextVideoTimeStreamController.add(_nextVideoTime); cancelNextVideoTimer(); } @@ -1191,15 +1209,14 @@ class BetterPlayerController { return headers; } - ///PreCache a video. Currently supports Android only. The future succeed when + ///PreCache a video. On Android, the future succeeds when ///the requested size, specified in ///[BetterPlayerCacheConfiguration.preCacheSize], is downloaded or when the ///complete file is downloaded if the file is smaller than the requested size. + ///On iOS, the whole file will be downloaded, since [maxCacheFileSize] is + ///currently not supported on iOS. On iOS, the video format must be in this + ///list: https://github.com/sendyhalim/Swime/blob/master/Sources/MimeType.swift Future preCache(BetterPlayerDataSource betterPlayerDataSource) async { - if (!Platform.isAndroid) { - return Future.error("preCache is currently only supported on Android."); - } - final cacheConfig = betterPlayerDataSource.cacheConfiguration ?? const BetterPlayerCacheConfiguration(useCache: true); @@ -1220,11 +1237,8 @@ class BetterPlayerController { ///cache started for given [betterPlayerDataSource] then it will be ignored. Future stopPreCache( BetterPlayerDataSource betterPlayerDataSource) async { - if (!Platform.isAndroid) { - return Future.error( - "stopPreCache is currently only supported on Android."); - } - return VideoPlayerController?.stopPreCache(betterPlayerDataSource.url); + return VideoPlayerController?.stopPreCache(betterPlayerDataSource.url, + betterPlayerDataSource.cacheConfiguration?.key); } /// Add controller internal event. @@ -1250,7 +1264,7 @@ class BetterPlayerController { } _eventListeners.clear(); _nextVideoTimer?.cancel(); - nextVideoTimeStreamController.close(); + _nextVideoTimeStreamController.close(); _controlsVisibilityStreamController.close(); _videoEventStreamSubscription?.cancel(); _disposed = true; diff --git a/lib/src/core/better_player_with_controls.dart b/lib/src/core/better_player_with_controls.dart index ae9d19d4b..506cbb76f 100644 --- a/lib/src/core/better_player_with_controls.dart +++ b/lib/src/core/better_player_with_controls.dart @@ -97,18 +97,21 @@ class _BetterPlayerWithControlsState extends State { } aspectRatio ??= 16 / 9; - - return Center( - child: Container( - width: double.infinity, - color: betterPlayerController - .betterPlayerConfiguration.controlsConfiguration.backgroundColor, - child: AspectRatio( - aspectRatio: aspectRatio, - child: _buildPlayerWithControls(betterPlayerController, context), - ), + final innerContainer = Container( + width: double.infinity, + color: betterPlayerController + .betterPlayerConfiguration.controlsConfiguration.backgroundColor, + child: AspectRatio( + aspectRatio: aspectRatio, + child: _buildPlayerWithControls(betterPlayerController, context), ), ); + + if (betterPlayerController.betterPlayerConfiguration.expandToFill) { + return Center(child: innerContainer); + } else { + return innerContainer; + } } Container _buildPlayerWithControls( diff --git a/lib/src/hls/better_player_hls_utils.dart b/lib/src/hls/better_player_hls_utils.dart index ef61faba8..b44e77d88 100644 --- a/lib/src/hls/better_player_hls_utils.dart +++ b/lib/src/hls/better_player_hls_utils.dart @@ -13,6 +13,7 @@ import 'package:better_player/src/hls/hls_parser/hls_media_playlist.dart'; import 'package:better_player/src/hls/hls_parser/hls_playlist_parser.dart'; import 'package:better_player/src/hls/hls_parser/rendition.dart'; import 'package:better_player/src/hls/hls_parser/segment.dart'; +import 'package:better_player/src/hls/hls_parser/util.dart'; ///HLS helper class class BetterPlayerHlsUtils { @@ -137,15 +138,22 @@ class BetterPlayerHlsUtils { targetDuration = parsedSubtitle.targetDurationUs! ~/ 1000; } + bool isDefault = false; + + if (rendition.format.selectionFlags != null) { + isDefault = + Util.checkBitPositionIsSet(rendition.format.selectionFlags!, 1); + } + return BetterPlayerAsmsSubtitle( - name: rendition.format.label, - language: rendition.format.language, - url: rendition.url.toString(), - realUrls: hlsSubtitlesUrls, - isSegmented: isSegmented, - segmentsTime: targetDuration, - segments: asmsSegments, - ); + name: rendition.format.label, + language: rendition.format.language, + url: rendition.url.toString(), + realUrls: hlsSubtitlesUrls, + isSegmented: isSegmented, + segmentsTime: targetDuration, + segments: asmsSegments, + isDefault: isDefault); } catch (exception) { BetterPlayerUtils.log("Failed to process subtitles playlist: $exception"); return null; diff --git a/lib/src/hls/hls_parser/format.dart b/lib/src/hls/hls_parser/format.dart index 0e348e79c..1b6f4ce9b 100644 --- a/lib/src/hls/hls_parser/format.dart +++ b/lib/src/hls/hls_parser/format.dart @@ -23,21 +23,24 @@ class Format { this.channelCount, String? language, this.accessibilityChannel, + this.isDefault, }) : language = language?.toLowerCase(); - factory Format.createVideoContainerFormat( - {String? id, - String? label, - String? containerMimeType, - String? sampleMimeType, - required String? codecs, - int? bitrate, - int? averageBitrate, - required int? width, - required int? height, - required double? frameRate, - int selectionFlags = Util.selectionFlagDefault, - int? roleFlags}) => + factory Format.createVideoContainerFormat({ + String? id, + String? label, + String? containerMimeType, + String? sampleMimeType, + required String? codecs, + int? bitrate, + int? averageBitrate, + required int? width, + required int? height, + required double? frameRate, + int selectionFlags = Util.selectionFlagDefault, + int? roleFlags, + bool? isDefault, + }) => Format( id: id, label: label, @@ -51,6 +54,7 @@ class Format { height: height, frameRate: frameRate, roleFlags: roleFlags, + isDefault: isDefault, ); /// An identifier for the format, or null if unknown or not applicable. @@ -111,6 +115,9 @@ class Format { /// The Accessibility channel, or null if not known or applicable. final int? accessibilityChannel; + /// If track is marked as default, or null if not known or applicable + final bool? isDefault; + Format copyWithMetadata(Metadata metadata) => Format( id: id, label: label, diff --git a/lib/src/hls/hls_parser/hls_playlist_parser.dart b/lib/src/hls/hls_parser/hls_playlist_parser.dart index 6ec85449b..b44f353fe 100644 --- a/lib/src/hls/hls_parser/hls_playlist_parser.dart +++ b/lib/src/hls/hls_parser/hls_playlist_parser.dart @@ -500,8 +500,7 @@ class HlsPlaylistParser { if (instreamId.startsWith('CC')) { mimeType = MimeTypes.applicationCea608; accessibilityChannel = int.parse(instreamId.substring(2)); - } else - /* starts with SERVICE */ { + } else /* starts with SERVICE */ { mimeType = MimeTypes.applicationCea708; accessibilityChannel = int.parse(instreamId.substring(7)); } @@ -602,6 +601,7 @@ class HlsPlaylistParser { static int _parseSelectionFlags(String line) { int flags = 0; + if (parseOptionalBooleanAttribute( line: line, pattern: regexpDefault, @@ -622,8 +622,14 @@ class HlsPlaylistParser { required String pattern, required bool defaultValue, }) { - final List list = line.allMatches(pattern).toList(); - return list.isEmpty ? defaultValue : list.first.pattern == booleanTrue; + final regExp = RegExp(pattern); + final List list = regExp.allMatches(line).toList(); + final ret = list.isEmpty + ? defaultValue + : line + .substring(list.first.start, list.first.end) + .contains(booleanTrue); + return ret; } static int _parseRoleFlags( @@ -830,8 +836,7 @@ class HlsPlaylistParser { if (methodNone == method) { currentSchemeDatas.clear(); cachedDrmInitData = null; - } else - /* !METHOD_NONE.equals(method) */ { + } else /* !METHOD_NONE.equals(method) */ { fullSegmentEncryptionIV = _parseStringAttr( source: line, pattern: regexpIv, diff --git a/lib/src/hls/hls_parser/util.dart b/lib/src/hls/hls_parser/util.dart index 5186175ca..d2fe2938f 100644 --- a/lib/src/hls/hls_parser/util.dart +++ b/lib/src/hls/hls_parser/util.dart @@ -126,6 +126,13 @@ class Util { static List splitCodecs(String? codecs) => codecs?.isNotEmpty != true ? [] : codecs!.trim().split(RegExp('(\\s*,\\s*)')); + + static bool checkBitPositionIsSet(int number, int bitPosition) { + if ((number & (1 << (bitPosition - 1))) > 0) + return true; + else + return false; + } } class CencType { diff --git a/lib/src/playlist/better_player_playlist_controller.dart b/lib/src/playlist/better_player_playlist_controller.dart index 48def68c8..99a0200cc 100644 --- a/lib/src/playlist/better_player_playlist_controller.dart +++ b/lib/src/playlist/better_player_playlist_controller.dart @@ -52,9 +52,8 @@ class BetterPlayerPlaylistController { _currentDataSourceIndex = initialStartIndex; setupDataSource(_currentDataSourceIndex); _betterPlayerController!.addEventsListener(_handleEvent); - _nextVideoTimeStreamSubscription = _betterPlayerController! - .nextVideoTimeStreamController.stream - .listen((time) { + _nextVideoTimeStreamSubscription = + _betterPlayerController!.nextVideoTimeStream.listen((time) { if (time != null && time == 0) { _onVideoChange(); } diff --git a/lib/src/video_player/method_channel_video_player.dart b/lib/src/video_player/method_channel_video_player.dart index c791d947e..ea8f5f06c 100644 --- a/lib/src/video_player/method_channel_video_player.dart +++ b/lib/src/video_player/method_channel_video_player.dart @@ -316,12 +316,10 @@ class MethodChannelVideoPlayer extends VideoPlayerPlatform { } @override - Future stopPreCache(String url) { + Future stopPreCache(String url, String? cacheKey) { return _channel.invokeMethod( 'stopPreCache', - { - 'url': url, - }, + {'url': url, 'cacheKey': cacheKey}, ); } diff --git a/lib/src/video_player/video_player.dart b/lib/src/video_player/video_player.dart index 7d686a429..03e3ad501 100644 --- a/lib/src/video_player/video_player.dart +++ b/lib/src/video_player/video_player.dart @@ -181,8 +181,11 @@ class VideoPlayerController extends ValueNotifier { /// Constructs a [VideoPlayerController] and creates video controller on platform side. VideoPlayerController({ this.bufferingConfiguration = const BetterPlayerBufferingConfiguration(), + bool autoCreate = true, }) : super(VideoPlayerValue(duration: null)) { - _create(); + if (autoCreate) { + _create(); + } } final StreamController videoEventStreamController = @@ -196,8 +199,6 @@ class VideoPlayerController extends ValueNotifier { StreamSubscription? _eventSubscription; bool get _created => _creatingCompleter.isCompleted; - - DateTime? _seekTime; Duration? _seekPosition; /// This is just exposed for testing. It shouldn't be used by anyone depending @@ -478,13 +479,11 @@ class VideoPlayerController extends ValueNotifier { return; } _updatePosition(newPosition, absolutePosition: newAbsolutePosition); - - if (_seekTime != null) { - final difference = DateTime.now().millisecondsSinceEpoch - - _seekTime!.millisecondsSinceEpoch; - if (difference > 400) { + if (_seekPosition != null && newPosition != null) { + final difference = + newPosition.inMilliseconds - _seekPosition!.inMilliseconds; + if (difference > 0) { _seekPosition = null; - _seekTime = null; } } }, @@ -531,6 +530,7 @@ class VideoPlayerController extends ValueNotifier { /// If [moment] is outside of the video's full range it will be automatically /// and silently clamped. Future seekTo(Duration? position) async { + _timer?.cancel(); bool isPlaying = value.isPlaying; final int positionInMs = value.position.inMilliseconds; final int durationInMs = value.duration?.inMilliseconds ?? 0; @@ -538,12 +538,9 @@ class VideoPlayerController extends ValueNotifier { if (positionInMs >= durationInMs && position?.inMilliseconds == 0) { isPlaying = true; } - if (_isDisposed) { return; } - _seekPosition = position; - _seekTime = DateTime.now(); Duration? positionToSeek = position; if (position! > value.duration!) { @@ -551,8 +548,11 @@ class VideoPlayerController extends ValueNotifier { } else if (position < const Duration()) { positionToSeek = const Duration(); } + _seekPosition = positionToSeek; + await _videoPlayerPlatform.seekTo(_textureId, positionToSeek); _updatePosition(position); + if (isPlaying) { play(); } else { @@ -637,8 +637,8 @@ class VideoPlayerController extends ValueNotifier { return _videoPlayerPlatform.preCache(dataSource, preCacheSize); } - static Future stopPreCache(String url) async { - return _videoPlayerPlatform.stopPreCache(url); + static Future stopPreCache(String url, String? cacheKey) async { + return _videoPlayerPlatform.stopPreCache(url, cacheKey); } } diff --git a/lib/src/video_player/video_player_platform_interface.dart b/lib/src/video_player/video_player_platform_interface.dart index bcbb510bb..060770884 100644 --- a/lib/src/video_player/video_player_platform_interface.dart +++ b/lib/src/video_player/video_player_platform_interface.dart @@ -82,7 +82,7 @@ abstract class VideoPlayerPlatform { } /// Pre-caches a video. - Future stopPreCache(String url) { + Future stopPreCache(String url, String? cacheKey) { throw UnimplementedError('stopPreCache() has not been implemented.'); } diff --git a/pubspec.lock b/pubspec.lock index 2be5ac9a2..ffe4090b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,7 +7,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.7.0" + version: "2.8.1" boolean_selector: dependency: transitive description: @@ -104,7 +104,7 @@ packages: name: flutter_widget_from_html_core url: "https://pub.dartlang.org" source: hosted - version: "0.6.1+1" + version: "0.7.0" html: dependency: transitive description: @@ -146,7 +146,7 @@ packages: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "1.7.0" path: dependency: transitive description: @@ -160,7 +160,7 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.5" path_provider_linux: dependency: transitive description: @@ -202,7 +202,7 @@ packages: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.3.0" platform: dependency: transitive description: @@ -270,7 +270,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.1" + version: "0.4.2" typed_data: dependency: transitive description: @@ -298,35 +298,35 @@ packages: name: wakelock url: "https://pub.dartlang.org" source: hosted - version: "0.5.2" + version: "0.5.6" wakelock_macos: dependency: transitive description: name: wakelock_macos url: "https://pub.dartlang.org" source: hosted - version: "0.1.0+1" + version: "0.4.0" wakelock_platform_interface: dependency: transitive description: name: wakelock_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "0.2.1+1" + version: "0.3.0" wakelock_web: dependency: transitive description: name: wakelock_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.0+1" + version: "0.4.0" wakelock_windows: dependency: transitive description: name: wakelock_windows url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.2.0" win32: dependency: transitive description: @@ -347,7 +347,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.1.2" + version: "5.3.0" sdks: - dart: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + dart: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" diff --git a/pubspec.yaml b/pubspec.yaml index 225b9ece6..5433f17fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,9 @@ name: better_player description: Advanced video player based on video_player and Chewie. It's solves many typical use cases and it's easy to run. -version: 0.0.73 -authors: - - Jakub Homlala +version: 0.0.74 +# Disabled because of warning from analyzer +# authors: +# - Jakub Homlala homepage: https://github.com/jhomlala/betterplayer documentation: https://jhomlala.github.io/betterplayer/ @@ -14,14 +15,14 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.3 - wakelock: ^0.5.2 + wakelock: ^0.5.6 pedantic: ^1.11.1 - meta: ^1.3.0 - flutter_widget_from_html_core: ^0.6.1+1 + meta: ^1.7.0 + flutter_widget_from_html_core: ^0.7.0 visibility_detector: ^0.2.0 - path_provider: ^2.0.2 + path_provider: ^2.0.5 collection: ^1.15.0 - xml: ^5.1.2 + xml: ^5.3.0 dev_dependencies: flutter_test: diff --git a/test/better_player_controller_test.dart b/test/better_player_controller_test.dart index 74ec8becf..285148df5 100644 --- a/test/better_player_controller_test.dart +++ b/test/better_player_controller_test.dart @@ -3,26 +3,31 @@ import 'package:flutter_test/flutter_test.dart'; import 'better_player_mock_controller.dart'; import 'better_player_test_utils.dart'; import 'mock_method_channel.dart'; - -MockMethodChannel? mockMethodChannel; +import 'mock_video_player_controller.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + MockMethodChannel mockMethodChannel = MockMethodChannel(); group( - "Controller tests", + "BetterPlayerController tests", () { - setUpAll(() { - mockMethodChannel = MockMethodChannel(); - }); - test("BetterPlayerController - create without data source", () { + setUp( + () => { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .setMockMethodCallHandler( + mockMethodChannel.channel, mockMethodChannel.handle) + }, + ); + + test("Create controller without data source", () { final BetterPlayerMockController betterPlayerMockController = BetterPlayerMockController(const BetterPlayerConfiguration()); expect(betterPlayerMockController.betterPlayerDataSource, null); expect(betterPlayerMockController.videoPlayerController, null); }); - test("BetterPlayerController - setup data source", () async { + test("Setup data source in controller", () async { final BetterPlayerMockController betterPlayerMockController = BetterPlayerMockController(const BetterPlayerConfiguration()); await betterPlayerMockController.setupDataSource( @@ -33,24 +38,28 @@ void main() { }); test( - "BetterPlayerController - play should change isPlaying flag", + "play should change isPlaying flag", () async { final BetterPlayerController betterPlayerController = - BetterPlayerController(const BetterPlayerConfiguration(), - betterPlayerDataSource: BetterPlayerDataSource.network( - BetterPlayerTestUtils.forBiggerBlazesUrl)); + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerController.videoPlayerController = videoPlayerController; + await Future.delayed(const Duration(seconds: 1), () {}); betterPlayerController.play(); expect(betterPlayerController.isPlaying(), true); }, ); test( - "BetterPlayerController - pause should change isPlaying flag", + "pause should change isPlaying flag", () async { final BetterPlayerController betterPlayerController = - BetterPlayerController(const BetterPlayerConfiguration(), - betterPlayerDataSource: BetterPlayerDataSource.network( - BetterPlayerTestUtils.forBiggerBlazesUrl)); + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerController.videoPlayerController = videoPlayerController; + await Future.delayed(const Duration(seconds: 1), () {}); betterPlayerController.play(); expect(betterPlayerController.isPlaying(), true); betterPlayerController.pause(); @@ -58,17 +67,370 @@ void main() { }, ); - test("BetterPlayerController - full screen and auto play should work", - () async { + test( + "seekTo should change player position", + () async { + final BetterPlayerController betterPlayerController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + videoPlayerController.setDuration(const Duration(seconds: 100)); + betterPlayerController.videoPlayerController = videoPlayerController; + betterPlayerController.seekTo(const Duration(seconds: 5)); + Duration? position = + await betterPlayerController.videoPlayerController!.position; + expect(position, const Duration(seconds: 5)); + betterPlayerController.seekTo(const Duration(seconds: 30)); + position = + await betterPlayerController.videoPlayerController!.position; + expect(position, const Duration(seconds: 30)); + }, + ); + + test( + "seekTo should send event", + () async { + final BetterPlayerController betterPlayerController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + videoPlayerController.setDuration(const Duration(seconds: 100)); + betterPlayerController.videoPlayerController = videoPlayerController; + + int seekEventCalls = 0; + int finishEventCalls = 0; + betterPlayerController.addEventsListener((event) { + if (event.betterPlayerEventType == BetterPlayerEventType.seekTo) { + seekEventCalls += 1; + } + if (event.betterPlayerEventType == BetterPlayerEventType.finished) { + finishEventCalls += 1; + } + }); + betterPlayerController.seekTo(const Duration(seconds: 5)); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(seekEventCalls, 1); + betterPlayerController.seekTo(const Duration(seconds: 150)); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(seekEventCalls, 2); + expect(finishEventCalls, 1); + }, + ); + + test("full screen and auto play should work", () async { final BetterPlayerMockController betterPlayerMockController = - BetterPlayerMockController(const BetterPlayerConfiguration( - fullScreenByDefault: true, autoPlay: true)); + BetterPlayerMockController( + const BetterPlayerConfiguration( + fullScreenByDefault: true, autoPlay: true), + ); + betterPlayerMockController.videoPlayerController = + MockVideoPlayerController(); await betterPlayerMockController.setupDataSource( - BetterPlayerDataSource.network( - BetterPlayerTestUtils.forBiggerBlazesUrl)); + BetterPlayerDataSource.network( + BetterPlayerTestUtils.forBiggerBlazesUrl), + ); + await Future.delayed(const Duration(seconds: 1), () {}); expect(betterPlayerMockController.isFullScreen, true); expect(betterPlayerMockController.isPlaying(), true); }); + + test("exitFullScreen should exit full screen", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController( + controller: MockVideoPlayerController(), + ); + expect(betterPlayerMockController.isFullScreen, false); + betterPlayerMockController.exitFullScreen(); + expect(betterPlayerMockController.isFullScreen, false); + }); + + test("enterFullScreen should enter full screen", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + await betterPlayerMockController.setupDataSource( + BetterPlayerDataSource.network( + BetterPlayerTestUtils.forBiggerBlazesUrl), + ); + expect(betterPlayerMockController.isFullScreen, false); + betterPlayerMockController.enterFullScreen(); + expect(betterPlayerMockController.isFullScreen, true); + }); + + test("toggleFullScreen should change full screen state", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + await betterPlayerMockController.setupDataSource( + BetterPlayerDataSource.network( + BetterPlayerTestUtils.forBiggerBlazesUrl), + ); + + expect(betterPlayerMockController.isFullScreen, false); + betterPlayerMockController.toggleFullScreen(); + expect(betterPlayerMockController.isFullScreen, true); + betterPlayerMockController.toggleFullScreen(); + expect(betterPlayerMockController.isFullScreen, false); + }); + + test("setLooping changes looping state", () async { + final mockVideoPlayerController = MockVideoPlayerController(); + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + mockVideoPlayerController + .setNetworkDataSource(BetterPlayerTestUtils.bugBuckBunnyVideoUrl); + + betterPlayerMockController.videoPlayerController = + mockVideoPlayerController; + expect(mockVideoPlayerController.isLoopingState, false); + betterPlayerMockController.setLooping(true); + expect(mockVideoPlayerController.isLoopingState, true); + betterPlayerMockController.setLooping(false); + expect(mockVideoPlayerController.isLoopingState, false); + }); + + test("setControlsVisibility updates controlVisiblityStream", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + var showCalls = 0; + var hideCalls = 0; + betterPlayerMockController.controlsVisibilityStream.listen((event) { + if (event) { + showCalls += 1; + } else { + hideCalls += 1; + } + }); + betterPlayerMockController.setControlsVisibility(false); + betterPlayerMockController.setControlsVisibility(false); + betterPlayerMockController.setControlsVisibility(true); + betterPlayerMockController.setControlsVisibility(true); + betterPlayerMockController.setControlsVisibility(false); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(hideCalls, 3); + expect(showCalls, 2); + }); + + test("setControlsEnabled updates values correctly", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + var hideCalls = 0; + betterPlayerMockController.controlsVisibilityStream.listen((event) { + hideCalls += 1; + }); + betterPlayerMockController.setControlsEnabled(false); + betterPlayerMockController.setControlsEnabled(false); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(hideCalls, 2); + expect(betterPlayerMockController.controlsEnabled, false); + betterPlayerMockController.setControlsEnabled(true); + expect(betterPlayerMockController.controlsEnabled, true); + }); + + test("toggleControlsVisibility sends correct events", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + var controlsVisibleEventCount = 0; + var controlsHiddenEventCount = 0; + betterPlayerMockController.addEventsListener((event) { + if (event.betterPlayerEventType == + BetterPlayerEventType.controlsVisible) { + controlsVisibleEventCount += 1; + } + if (event.betterPlayerEventType == + BetterPlayerEventType.controlsHidden) { + controlsHiddenEventCount += 1; + } + }); + betterPlayerMockController.toggleControlsVisibility(false); + betterPlayerMockController.toggleControlsVisibility(true); + betterPlayerMockController.toggleControlsVisibility(true); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(controlsVisibleEventCount, 2); + expect(controlsHiddenEventCount, 1); + }); + + test("postEvent sends events to listeners", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + + int firstEventCounter = 0; + int secondEventCounter = 0; + + betterPlayerMockController.addEventsListener((event) { + firstEventCounter++; + }); + betterPlayerMockController.addEventsListener((event) { + secondEventCounter++; + }); + betterPlayerMockController + .postEvent(BetterPlayerEvent(BetterPlayerEventType.play)); + betterPlayerMockController + .postEvent(BetterPlayerEvent(BetterPlayerEventType.progress)); + + betterPlayerMockController + .postEvent(BetterPlayerEvent(BetterPlayerEventType.pause)); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(firstEventCounter, 3); + expect(secondEventCounter, 3); + }); + + test("addEventsListener update list of event listener", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + betterPlayerMockController.addEventsListener((event) {}); + betterPlayerMockController.addEventsListener((event) {}); + expect(betterPlayerMockController.eventListeners.length, 2); + }); + + void dummyEventListener(BetterPlayerEvent event) {} + + test("removeEventsListener update list of event listener", () async { + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + betterPlayerMockController.addEventsListener(dummyEventListener); + betterPlayerMockController.addEventsListener((event) {}); + expect(betterPlayerMockController.eventListeners.length, 2); + betterPlayerMockController.removeEventsListener(dummyEventListener); + expect(betterPlayerMockController.eventListeners.length, 1); + }); + + test("setVolume changes volume", () async { + final mockVideoPlayerController = MockVideoPlayerController(); + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + mockVideoPlayerController + .setNetworkDataSource(BetterPlayerTestUtils.bugBuckBunnyVideoUrl); + betterPlayerMockController.videoPlayerController = + mockVideoPlayerController; + betterPlayerMockController.setVolume(1.0); + expect(mockVideoPlayerController.volume, 1.0); + betterPlayerMockController.setVolume(0.5); + expect(mockVideoPlayerController.volume, 0.5); + }); + + test( + "setVolume should send event", + () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerMockController.videoPlayerController = + videoPlayerController; + + int setVolumeCalls = 0; + betterPlayerMockController.addEventsListener((event) { + if (event.betterPlayerEventType == + BetterPlayerEventType.setVolume) { + setVolumeCalls += 1; + } + }); + betterPlayerMockController.setVolume(1.0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(setVolumeCalls, 1); + betterPlayerMockController.setVolume(1.0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(setVolumeCalls, 2); + }, + ); + + test("setSpeed changes speed", () async { + final mockVideoPlayerController = MockVideoPlayerController(); + final BetterPlayerMockController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + mockVideoPlayerController + .setNetworkDataSource(BetterPlayerTestUtils.bugBuckBunnyVideoUrl); + betterPlayerMockController.videoPlayerController = + mockVideoPlayerController; + betterPlayerMockController.setSpeed(1.1); + expect(mockVideoPlayerController.speed, 1.1); + betterPlayerMockController.setSpeed(0.5); + expect(mockVideoPlayerController.speed, 0.5); + expect(() => betterPlayerMockController.setSpeed(2.5), + throwsA(isA())); + expect(mockVideoPlayerController.speed, 0.5); + expect(() => betterPlayerMockController.setSpeed(0.0), + throwsA(isA())); + expect(mockVideoPlayerController.speed, 0.5); + }); + + test( + "setSpeed should send event", + () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerMockController.videoPlayerController = + videoPlayerController; + + int setSpeedCalls = 0; + betterPlayerMockController.addEventsListener((event) { + if (event.betterPlayerEventType == BetterPlayerEventType.setSpeed) { + setSpeedCalls += 1; + } + }); + betterPlayerMockController.setSpeed(1.5); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(setSpeedCalls, 1); + betterPlayerMockController.setSpeed(1.0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(setSpeedCalls, 2); + }, + ); + + test("isBuffering returns valid value", () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerMockController.videoPlayerController = + videoPlayerController; + videoPlayerController.setBuffering(false); + expect(betterPlayerMockController.isBuffering(), false); + videoPlayerController.setBuffering(true); + expect(betterPlayerMockController.isBuffering(), true); + }); + + test("isLiveStream returns valid value", () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + expect(() => betterPlayerMockController.isLiveStream(), + throwsA(isA())); + betterPlayerMockController.setupDataSource(BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + BetterPlayerTestUtils.forBiggerBlazesUrl, + liveStream: true)); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerMockController.videoPlayerController = + videoPlayerController; + expect(betterPlayerMockController.isLiveStream(), true); + }); + + test("isVideoInitalized returns valid value", () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + expect(() => betterPlayerMockController.isVideoInitialized(), + throwsA(isA())); + final videoPlayerController = + BetterPlayerTestUtils.setupMockVideoPlayerControler(); + betterPlayerMockController.videoPlayerController = + videoPlayerController; + videoPlayerController.setDuration(const Duration(seconds: 1)); + expect(betterPlayerMockController.isVideoInitialized(), true); + }); + + test("startNextVideoTimer starts next video timer", () async { + final BetterPlayerController betterPlayerMockController = + BetterPlayerTestUtils.setupBetterPlayerMockController(); + int eventCount = 0; + betterPlayerMockController.nextVideoTimeStream.listen((event) { + eventCount += 1; + }); + betterPlayerMockController.startNextVideoTimer(); + await Future.delayed(const Duration(milliseconds: 3000), () {}); + expect(eventCount, 3); + }); }, ); } diff --git a/test/better_player_controls_test.dart b/test/better_player_controls_test.dart new file mode 100644 index 000000000..773687b6b --- /dev/null +++ b/test/better_player_controls_test.dart @@ -0,0 +1,43 @@ +import 'package:better_player/better_player.dart'; +import 'package:better_player/src/core/better_player_with_controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import 'better_player_mock_controller.dart'; + +void main() { + late BetterPlayerMockController _mockController; + + setUpAll(() { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + }); + + setUp(() { + _mockController = + BetterPlayerMockController(const BetterPlayerConfiguration()); + }); + + testWidgets( + "One of children is BetterPlayerWithControls", + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWidget( + BetterPlayer( + controller: _mockController, + ), + ), + ); + expect( + find.byWidgetPredicate( + (widget) => widget is BetterPlayerWithControls), + findsOneWidget); + }, + ); +} + +///Wrap widget with material app to handle all features like navigation and +///localization properly. +Widget _wrapWidget(Widget widget) { + return MaterialApp(home: widget); +} diff --git a/test/better_player_mock_controller.dart b/test/better_player_mock_controller.dart index 7b291f7ee..207262421 100644 --- a/test/better_player_mock_controller.dart +++ b/test/better_player_mock_controller.dart @@ -2,6 +2,10 @@ import 'package:better_player/better_player.dart'; class BetterPlayerMockController extends BetterPlayerController { BetterPlayerMockController( - BetterPlayerConfiguration betterPlayerConfiguration) - : super(betterPlayerConfiguration); + BetterPlayerConfiguration betterPlayerConfiguration, { + BetterPlayerPlaylistConfiguration betterPlayerPlaylistConfiguration = + const BetterPlayerPlaylistConfiguration(), + }) : super(betterPlayerConfiguration, + betterPlayerPlaylistConfiguration: + betterPlayerPlaylistConfiguration); } diff --git a/test/better_player_test.dart b/test/better_player_test.dart index 8067e2c01..c794b963d 100644 --- a/test/better_player_test.dart +++ b/test/better_player_test.dart @@ -2,16 +2,12 @@ import 'package:better_player/better_player.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:visibility_detector/visibility_detector.dart'; - import 'better_player_mock_controller.dart'; import 'better_player_test_utils.dart'; -import 'mock_method_channel.dart'; void main() { setUpAll(() { VisibilityDetectorController.instance.updateInterval = Duration.zero; - // ignore: unused_local_variable - final MockMethodChannel mockMethodChannel = MockMethodChannel(); }); testWidgets("Better Player simple player - network", diff --git a/test/better_player_test_utils.dart b/test/better_player_test_utils.dart index 93da7519e..c3678433f 100644 --- a/test/better_player_test_utils.dart +++ b/test/better_player_test_utils.dart @@ -1,3 +1,9 @@ +import 'package:better_player/better_player.dart'; +import 'package:better_player/src/video_player/video_player.dart'; + +import 'better_player_mock_controller.dart'; +import 'mock_video_player_controller.dart'; + class BetterPlayerTestUtils { static const String bugBuckBunnyVideoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; @@ -5,4 +11,21 @@ class BetterPlayerTestUtils { "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"; static const String elephantDreamStreamUrl = "http://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8"; + + static BetterPlayerMockController setupBetterPlayerMockController( + {VideoPlayerController? controller}) { + final mockController = + BetterPlayerMockController(const BetterPlayerConfiguration()); + if (controller != null) { + mockController.videoPlayerController = controller; + } + return mockController; + } + + static MockVideoPlayerController setupMockVideoPlayerControler() { + final mockVideoPlayerController = MockVideoPlayerController(); + mockVideoPlayerController + .setNetworkDataSource(BetterPlayerTestUtils.forBiggerBlazesUrl); + return mockVideoPlayerController; + } } diff --git a/test/mock_method_channel.dart b/test/mock_method_channel.dart index d04d67704..2644e923b 100644 --- a/test/mock_method_channel.dart +++ b/test/mock_method_channel.dart @@ -1,21 +1,21 @@ import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; class MockMethodChannel { final MethodChannel channel = const MethodChannel("better_player_channel"); final List eventsChannels = []; - MockMethodChannel() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == "create") { - final int id = getNextId(); - _createEventChannel(id); - return _getCreateResult(id); - } - if (methodCall.method == "setDataSource") { - return null; - } - return {}; - }); + Future? handle(MethodCall methodCall) async { + print("Handle: " + methodCall.toString()); + if (methodCall.method == "create") { + final int id = getNextId(); + _createEventChannel(id); + return _getCreateResult(id); + } + if (methodCall.method == "setDataSource") { + //return + } + return {}; } int getNextId() { @@ -35,12 +35,14 @@ class MockMethodChannel { void _createEventChannel(int id) { final MethodChannel eventChannel = MethodChannel("better_player_channel/videoEvents$id"); - - eventChannel.setMockMethodCallHandler((MethodCall methodCall) async { - ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - "better_player_channel/videoEvents$id", - const StandardMethodCodec().encodeSuccessEnvelope(_getInitResult()), - (ByteData? data) {}); + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .setMockMethodCallHandler(eventChannel, (MethodCall methodCall) async { + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .handlePlatformMessage( + "better_player_channel/videoEvents$id", + const StandardMethodCodec() + .encodeSuccessEnvelope(_getInitResult()), + (ByteData? data) {}); }); eventsChannels.add(eventChannel); diff --git a/test/mock_video_player_controller.dart b/test/mock_video_player_controller.dart new file mode 100644 index 000000000..18bf88436 --- /dev/null +++ b/test/mock_video_player_controller.dart @@ -0,0 +1,75 @@ +import 'package:better_player/src/video_player/video_player.dart'; +import 'package:better_player/src/video_player/video_player_platform_interface.dart'; + +class MockVideoPlayerController extends VideoPlayerController { + MockVideoPlayerController() : super(autoCreate: false) { + value = VideoPlayerValue(duration: const Duration()); + } + + bool isLoopingState = false; + double volume = 0.0; + double speed = 1.0; + + @override + Future play() async { + value = value.copyWith(isPlaying: true); + return; + } + + @override + Future pause() async { + value = value.copyWith(isPlaying: false); + return; + } + + @override + Future setLooping(bool looping) async { + isLoopingState = looping; + } + + void setBuffering(bool buffering) { + value = value.copyWith(isBuffering: buffering); + } + + void setDuration(Duration duration) { + value = value.copyWith(duration: duration); + } + + @override + Future seekTo(Duration? position) async { + value = value.copyWith(position: position); + } + + @override + Future get position async => value.position; + + @override + Future setVolume(double volume) async { + this.volume = volume; + } + + @override + Future setSpeed(double speed) async { + this.speed = speed; + } + + @override + Future setNetworkDataSource(String dataSource, + {VideoFormat? formatHint, + Map? headers, + bool useCache = false, + int? maxCacheSize, + int? maxCacheFileSize, + String? cacheKey, + bool? showNotification, + String? title, + String? author, + String? imageUrl, + String? notificationChannelName, + Duration? overriddenDuration, + String? licenseUrl, + String? certificateUrl, + Map? drmHeaders, + String? activityName, + String? clearKey}) async {} +}