diff --git a/packages/video_player/video_player_avfoundation/CHANGELOG.md b/packages/video_player/video_player_avfoundation/CHANGELOG.md index 3c9cc2d371fd..cc4411c8c651 100644 --- a/packages/video_player/video_player_avfoundation/CHANGELOG.md +++ b/packages/video_player/video_player_avfoundation/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.3.6 +* Fixes a bug in iOS 16 where videos from protected live streams are not shown. * Updates minimum Flutter version to 2.10. * Fixes violations of new analysis option use_named_constants. * Fixes avoid_redundant_argument_values lint warnings and minor typos. diff --git a/packages/video_player/video_player_avfoundation/example/ios/RunnerTests/VideoPlayerTests.m b/packages/video_player/video_player_avfoundation/example/ios/RunnerTests/VideoPlayerTests.m index 7decd04bd168..813fca2b8e7d 100644 --- a/packages/video_player/video_player_avfoundation/example/ios/RunnerTests/VideoPlayerTests.m +++ b/packages/video_player/video_player_avfoundation/example/ios/RunnerTests/VideoPlayerTests.m @@ -11,6 +11,10 @@ @interface FLTVideoPlayer : NSObject @property(readonly, nonatomic) AVPlayer *player; +// This is to fix a bug (https://github.com/flutter/flutter/issues/111457) in iOS 16 with blank +// video for encrypted video streams. An invisible AVPlayerLayer is used to overwrite the +// protection of pixel buffers in those streams. +@property(readonly, nonatomic) AVPlayerLayer *playerLayer; @end @interface FLTVideoPlayerPlugin (Test) @@ -61,6 +65,45 @@ @interface VideoPlayerTests : XCTestCase @implementation VideoPlayerTests +- (void)testIOS16BugWithEncryptedVideoStream { + // This is to fix a bug (https://github.com/flutter/flutter/issues/111457) in iOS 16 with blank + // video for encrypted video streams. An invisible AVPlayerLayer is used to overwrite the + // protection of pixel buffers in those streams. + // Note that a better unit test is to validate that `copyPixelBuffer` API returns the pixel + // buffers as expected, which requires setting up the video player properly with a protected video + // stream (.m3u8 file). + NSObject *registry = + (NSObject *)[[UIApplication sharedApplication] delegate]; + NSObject *registrar = + [registry registrarForPlugin:@"testPlayerLayerWorkaround"]; + FLTVideoPlayerPlugin *videoPlayerPlugin = + [[FLTVideoPlayerPlugin alloc] initWithRegistrar:registrar]; + + FlutterError *error; + [videoPlayerPlugin initialize:&error]; + XCTAssertNil(error); + + FLTCreateMessage *create = [FLTCreateMessage + makeWithAsset:nil + uri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4" + packageName:nil + formatHint:nil + httpHeaders:@{}]; + FLTTextureMessage *textureMessage = [videoPlayerPlugin create:create error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(textureMessage); + FLTVideoPlayer *player = videoPlayerPlugin.playersByTextureId[textureMessage.textureId]; + XCTAssertNotNil(player); + + if (@available(iOS 16.0, *)) { + XCTAssertNotNil(player.playerLayer, @"AVPlayerLayer should be present for iOS 16."); + XCTAssertNotNil(player.playerLayer.superlayer, + @"AVPlayerLayer should be added on screen for iOS 16."); + } else { + XCTAssertNil(player.playerLayer, @"AVPlayerLayer should not be present before iOS 16."); + } +} + - (void)testSeekToInvokesTextureFrameAvailableOnTextureRegistry { NSObject *mockTextureRegistry = OCMProtocolMock(@protocol(FlutterTextureRegistry)); diff --git a/packages/video_player/video_player_avfoundation/example/ios/RunnerUITests/VideoPlayerUITests.m b/packages/video_player/video_player_avfoundation/example/ios/RunnerUITests/VideoPlayerUITests.m index b9f0f16bb27b..531d4fdf213c 100644 --- a/packages/video_player/video_player_avfoundation/example/ios/RunnerUITests/VideoPlayerUITests.m +++ b/packages/video_player/video_player_avfoundation/example/ios/RunnerUITests/VideoPlayerUITests.m @@ -4,6 +4,7 @@ @import os.log; @import XCTest; +@import CoreGraphics; @interface VideoPlayerUITests : XCTestCase @property(nonatomic, strong) XCUIApplication *app; @@ -46,7 +47,7 @@ - (void)testPlayVideo { XCTAssertTrue(foundPlaybackSpeed5x); // Cycle through tabs. - for (NSString *tabName in @[ @"Asset", @"Remote" ]) { + for (NSString *tabName in @[ @"Asset mp4", @"Remote mp4" ]) { NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label BEGINSWITH %@", tabName]; XCUIElement *unselectedTab = [app.staticTexts elementMatchingPredicate:predicate]; XCTAssertTrue([unselectedTab waitForExistenceWithTimeout:30.0]); @@ -60,4 +61,54 @@ - (void)testPlayVideo { } } +- (void)testEncryptedVideoStream { + // This is to fix a bug (https://github.com/flutter/flutter/issues/111457) in iOS 16 with blank + // video for encrypted video streams. + + NSString *tabName = @"Remote enc m3u8"; + + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label BEGINSWITH %@", tabName]; + XCUIElement *unselectedTab = [self.app.staticTexts elementMatchingPredicate:predicate]; + XCTAssertTrue([unselectedTab waitForExistenceWithTimeout:30.0]); + XCTAssertFalse(unselectedTab.isSelected); + [unselectedTab tap]; + + XCUIElement *selectedTab = [self.app.otherElements + elementMatchingPredicate:[NSPredicate predicateWithFormat:@"label BEGINSWITH %@", tabName]]; + XCTAssertTrue([selectedTab waitForExistenceWithTimeout:30.0]); + XCTAssertTrue(selectedTab.isSelected); + + // Wait until the video is loaded. + [NSThread sleepForTimeInterval:60]; + + NSMutableSet *frames = [NSMutableSet set]; + int numberOfFrames = 60; + for (int i = 0; i < numberOfFrames; i++) { + UIImage *image = self.app.screenshot.image; + + // Plugin CI does not support attaching screenshot. + // Convert the image to base64 encoded string, and print it out for debugging purpose. + // NSLog truncates long strings, so need to scale downn image. + CGSize smallerSize = CGSizeMake(100, 200); + UIGraphicsBeginImageContextWithOptions(smallerSize, NO, 0.0); + [image drawInRect:CGRectMake(0, 0, smallerSize.width, smallerSize.height)]; + UIImage *smallerImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + // 0.5 compression is good enough for debugging purpose. + NSData *imageData = UIImageJPEGRepresentation(smallerImage, 0.5); + NSString *imageString = [imageData base64EncodedStringWithOptions:0]; + NSLog(@"frame %d image data:\n%@", i, imageString); + + [frames addObject:imageString]; + + // The sample interval must NOT be the same as video length. + // Otherwise it would always result in the same frame. + [NSThread sleepForTimeInterval:1]; + } + + // At least 1 loading and 2 distinct frames (3 in total) to validate that the video is playing. + XCTAssert(frames.count >= 3, @"Must have at least 3 distinct frames."); +} + @end diff --git a/packages/video_player/video_player_avfoundation/example/lib/main.dart b/packages/video_player/video_player_avfoundation/example/lib/main.dart index bca4e291efff..d385fd0ee66a 100644 --- a/packages/video_player/video_player_avfoundation/example/lib/main.dart +++ b/packages/video_player/video_player_avfoundation/example/lib/main.dart @@ -20,7 +20,7 @@ class _App extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 2, + length: 3, child: Scaffold( key: const ValueKey('home_page'), appBar: AppBar( @@ -30,15 +30,20 @@ class _App extends StatelessWidget { tabs: [ Tab( icon: Icon(Icons.cloud), - text: 'Remote', + text: 'Remote mp4', ), - Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'), + Tab( + icon: Icon(Icons.favorite), + text: 'Remote enc m3u8', + ), + Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset mp4'), ], ), ), body: TabBarView( children: [ _BumbleBeeRemoteVideo(), + _BumbleBeeEncryptedLiveStream(), _ButterFlyAssetVideo(), ], ), @@ -156,6 +161,59 @@ class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> { } } +class _BumbleBeeEncryptedLiveStream extends StatefulWidget { + @override + _BumbleBeeEncryptedLiveStreamState createState() => + _BumbleBeeEncryptedLiveStreamState(); +} + +class _BumbleBeeEncryptedLiveStreamState + extends State<_BumbleBeeEncryptedLiveStream> { + late MiniController _controller; + + @override + void initState() { + super.initState(); + _controller = MiniController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/hls/encrypted_bee.m3u8', + ); + + _controller.addListener(() { + setState(() {}); + }); + _controller.initialize(); + + _controller.play(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + Container(padding: const EdgeInsets.only(top: 20.0)), + const Text('With remote encrypted m3u8'), + Container( + padding: const EdgeInsets.all(20), + child: _controller.value.isInitialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ) + : const Text('loading...'), + ), + ], + ), + ); + } +} + class _ControlsOverlay extends StatelessWidget { const _ControlsOverlay({Key? key, required this.controller}) : super(key: key); diff --git a/packages/video_player/video_player_avfoundation/ios/Classes/FLTVideoPlayerPlugin.m b/packages/video_player/video_player_avfoundation/ios/Classes/FLTVideoPlayerPlugin.m index a95779b1cbab..645c86d6eade 100644 --- a/packages/video_player/video_player_avfoundation/ios/Classes/FLTVideoPlayerPlugin.m +++ b/packages/video_player/video_player_avfoundation/ios/Classes/FLTVideoPlayerPlugin.m @@ -36,6 +36,8 @@ - (void)onDisplayLink:(CADisplayLink *)link { @interface FLTVideoPlayer : NSObject @property(readonly, nonatomic) AVPlayer *player; @property(readonly, nonatomic) AVPlayerItemVideoOutput *videoOutput; +/// An invisible player layer used to access the pixel buffers in protected video streams in iOS 16. +@property(readonly, nonatomic) AVPlayerLayer *playerLayer; @property(readonly, nonatomic) CADisplayLink *displayLink; @property(nonatomic) FlutterEventChannel *eventChannel; @property(nonatomic) FlutterEventSink eventSink; @@ -132,6 +134,19 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) { return degrees; }; +NS_INLINE UIViewController *rootViewController() API_AVAILABLE(ios(16.0)) { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if ([scene isKindOfClass:UIWindowScene.class]) { + for (UIWindow *window in ((UIWindowScene *)scene).windows) { + if (window.isKeyWindow) { + return window.rootViewController; + } + } + } + } + return nil; +} + - (AVMutableVideoComposition *)getVideoCompositionWithTransform:(CGAffineTransform)transform withAsset:(AVAsset *)asset withVideoTrack:(AVAssetTrack *)videoTrack { @@ -227,6 +242,14 @@ - (instancetype)initWithPlayerItem:(AVPlayerItem *)item _player = [AVPlayer playerWithPlayerItem:item]; _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; + // This is to fix a bug (https://github.com/flutter/flutter/issues/111457) in iOS 16 with blank + // video for encrypted video streams. An invisible AVPlayerLayer is used to overwrite the + // protection of pixel buffers in those streams. + if (@available(iOS 16.0, *)) { + _playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; + [rootViewController().view.layer addSublayer:_playerLayer]; + } + [self createVideoOutputAndDisplayLink:frameUpdater]; [self addObservers:item]; @@ -458,6 +481,9 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments /// so the channel is going to die or is already dead. - (void)disposeSansEventChannel { _disposed = YES; + if (@available(iOS 16.0, *)) { + [_playerLayer removeFromSuperlayer]; + } [_displayLink invalidate]; AVPlayerItem *currentItem = self.player.currentItem; [currentItem removeObserver:self forKeyPath:@"status"]; diff --git a/packages/video_player/video_player_avfoundation/pubspec.yaml b/packages/video_player/video_player_avfoundation/pubspec.yaml index 06042c34bad6..bd88ddf94876 100644 --- a/packages/video_player/video_player_avfoundation/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_avfoundation description: iOS implementation of the video_player plugin. repository: https://github.com/flutter/plugins/tree/main/packages/video_player/video_player_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.3.5 +version: 2.3.6 environment: sdk: ">=2.14.0 <3.0.0"