diff --git a/packages/image_picker/example/lib/main.dart b/packages/image_picker/example/lib/main.dart index 4dceb56e4ff2..79ceb35a61f6 100755 --- a/packages/image_picker/example/lib/main.dart +++ b/packages/image_picker/example/lib/main.dart @@ -226,10 +226,9 @@ class AspectRatioVideoState extends State { @override Widget build(BuildContext context) { if (initialized) { - final Size size = controller.value.size; return Center( child: AspectRatio( - aspectRatio: size.width / size.height, + aspectRatio: controller.value?.aspectRatio, child: VideoPlayer(controller), ), ); diff --git a/packages/video_player/CHANGELOG.md b/packages/video_player/CHANGELOG.md index 3ce24012c30e..338fb22395ef 100644 --- a/packages/video_player/CHANGELOG.md +++ b/packages/video_player/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.9.0 + +* Fixed the aspect ratio and orientation of videos. Videos are now properly displayed when recorded + in portrait mode both in iOS and Android. + ## 0.8.0 * Android: Upgrade ExoPlayer to 2.9.1 diff --git a/packages/video_player/README.md b/packages/video_player/README.md index 3a2a399724f9..fe9a8accff65 100644 --- a/packages/video_player/README.md +++ b/packages/video_player/README.md @@ -55,22 +55,12 @@ class VideoApp extends StatefulWidget { class _VideoAppState extends State { VideoPlayerController _controller; - bool _isPlaying = false; @override void initState() { super.initState(); _controller = VideoPlayerController.network( - 'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4', - ) - ..addListener(() { - final bool isPlaying = _controller.value.isPlaying; - if (isPlaying != _isPlaying) { - setState(() { - _isPlaying = isPlaying; - }); - } - }) + 'http://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4') ..initialize().then((_) { // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. setState(() {}); @@ -91,9 +81,13 @@ class _VideoAppState extends State { : Container(), ), floatingActionButton: FloatingActionButton( - onPressed: _controller.value.isPlaying - ? _controller.pause - : _controller.play, + onPressed: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, child: Icon( _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, ), @@ -101,5 +95,11 @@ class _VideoAppState extends State { ), ); } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } } ``` diff --git a/packages/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java index 2b730214068d..de4f44cdb567 100644 --- a/packages/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java +++ b/packages/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java @@ -15,6 +15,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DefaultEventListener; import com.google.android.exoplayer2.SimpleExoPlayer; @@ -51,6 +52,8 @@ public class VideoPlayerPlugin implements MethodCallHandler { + private static final String TAG = "VideoPlayerPlugin"; + private static class VideoPlayer { private SimpleExoPlayer exoPlayer; @@ -220,9 +223,19 @@ private void sendInitialized() { Map event = new HashMap<>(); event.put("event", "initialized"); event.put("duration", exoPlayer.getDuration()); + if (exoPlayer.getVideoFormat() != null) { - event.put("width", exoPlayer.getVideoFormat().width); - event.put("height", exoPlayer.getVideoFormat().height); + Format videoFormat = exoPlayer.getVideoFormat(); + int width = videoFormat.width; + int height = videoFormat.height; + int rotationDegrees = videoFormat.rotationDegrees; + // Switch the width/height if video was taken in portrait mode + if (rotationDegrees == 90 || rotationDegrees == 270) { + width = exoPlayer.getVideoFormat().height; + height = exoPlayer.getVideoFormat().width; + } + event.put("width", width); + event.put("height", height); } eventSink.success(event); } diff --git a/packages/video_player/example/lib/main.dart b/packages/video_player/example/lib/main.dart index d1cec7fcc68a..696373b0a5b3 100644 --- a/packages/video_player/example/lib/main.dart +++ b/packages/video_player/example/lib/main.dart @@ -380,7 +380,7 @@ void main() { Column( children: [ NetworkPlayerLifeCycle( - 'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4', + 'http://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4', (BuildContext context, VideoPlayerController controller) => AspectRatioVideo(controller), ), @@ -395,7 +395,7 @@ void main() { ], ), NetworkPlayerLifeCycle( - 'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4', + 'http://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4', (BuildContext context, VideoPlayerController controller) => AspectRatioVideo(controller), ), diff --git a/packages/video_player/ios/Classes/VideoPlayerPlugin.m b/packages/video_player/ios/Classes/VideoPlayerPlugin.m index aeec622ce7b6..16110a5e4e47 100644 --- a/packages/video_player/ios/Classes/VideoPlayerPlugin.m +++ b/packages/video_player/ios/Classes/VideoPlayerPlugin.m @@ -4,6 +4,7 @@ #import "VideoPlayerPlugin.h" #import +#import int64_t FLTCMTimeToMillis(CMTime time) { return time.value * 1000 / time.timescale; } @@ -32,6 +33,7 @@ @interface FLTVideoPlayer : NSObject @property(readonly, nonatomic) CADisplayLink* displayLink; @property(nonatomic) FlutterEventChannel* eventChannel; @property(nonatomic) FlutterEventSink eventSink; +@property(nonatomic) CGAffineTransform preferredTransform; @property(nonatomic, readonly) bool disposed; @property(nonatomic, readonly) bool isPlaying; @property(nonatomic, readonly) bool isLooping; @@ -55,14 +57,7 @@ - (instancetype)initWithAsset:(NSString*)asset frameUpdater:(FLTFrameUpdater*)fr return [self initWithURL:[NSURL fileURLWithPath:path] frameUpdater:frameUpdater]; } -- (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpdater { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - _isInitialized = false; - _isPlaying = false; - _disposed = false; - - AVPlayerItem* item = [AVPlayerItem playerItemWithURL:url]; +- (void)addObservers:(AVPlayerItem*)item { [item addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew @@ -84,8 +79,6 @@ - (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpda options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:playbackBufferFullContext]; - _player = [AVPlayer playerWithPlayerItem:item]; - _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; [[NSNotificationCenter defaultCenter] addObserverForName:AVPlayerItemDidPlayToEndTimeNotification object:[_player currentItem] queue:[NSOperationQueue mainQueue] @@ -99,12 +92,89 @@ - (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpda } } }]; +} + +- (AVMutableVideoComposition*)getVideoCompositionWithTransform:(CGAffineTransform)transform + withAsset:(AVAsset*)asset + withVideoTrack:(AVAssetTrack*)videoTrack { + AVMutableVideoCompositionInstruction* instruction = + [AVMutableVideoCompositionInstruction videoCompositionInstruction]; + instruction.timeRange = CMTimeRangeMake(kCMTimeZero, [asset duration]); + AVMutableVideoCompositionLayerInstruction* layerInstruction = + [AVMutableVideoCompositionLayerInstruction + videoCompositionLayerInstructionWithAssetTrack:videoTrack]; + [layerInstruction setTransform:_preferredTransform atTime:kCMTimeZero]; + + AVMutableVideoComposition* videoComposition = [AVMutableVideoComposition videoComposition]; + instruction.layerInstructions = @[ layerInstruction ]; + videoComposition.instructions = @[ instruction ]; + + // If in portrait mode, switch the width and height of the video + float width = videoTrack.naturalSize.width; + float height = videoTrack.naturalSize.height; + NSInteger rotationDegrees = + (NSInteger)round(radiansToDegrees(atan2(_preferredTransform.b, _preferredTransform.a))); + if (rotationDegrees == 90 || rotationDegrees == 270) { + width = videoTrack.naturalSize.height; + height = videoTrack.naturalSize.width; + } + videoComposition.renderSize = CGSizeMake(width, height); + + // TODO(@recastrodiaz): should we use videoTrack.nominalFrameRate ? + // Currently set at a constant 30 FPS + videoComposition.frameDuration = CMTimeMake(1, 30); + + return videoComposition; +} + +- (void)createVideoOutputAndDisplayLink:(FLTFrameUpdater*)frameUpdater { NSDictionary* pixBuffAttributes = @{ (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), (id)kCVPixelBufferIOSurfacePropertiesKey : @{} }; _videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:pixBuffAttributes]; + _displayLink = [CADisplayLink displayLinkWithTarget:frameUpdater + selector:@selector(onDisplayLink:)]; + [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; + _displayLink.paused = YES; +} + +- (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpdater { + AVPlayerItem* item = [AVPlayerItem playerItemWithURL:url]; + return [self initWithPlayerItem:item frameUpdater:frameUpdater]; +} + +- (CGAffineTransform)fixTransform:(AVAssetTrack*)videoTrack { + CGAffineTransform transform = videoTrack.preferredTransform; + // TODO(@recastrodiaz): why do we need to do this? Why is the preferredTransform incorrect? + // At least 2 user videos show a black screen when in portrait mode if we directly use the + // videoTrack.preferredTransform Setting tx to the height of the video instead of 0, properly + // displays the video https://github.com/flutter/flutter/issues/17606#issuecomment-413473181 + if (transform.tx == 0 && transform.ty == 0) { + NSInteger rotationDegrees = (NSInteger)round(radiansToDegrees(atan2(transform.b, transform.a))); + NSLog(@"TX and TY are 0. Rotation: %d. Natural width,height: %f, %f", rotationDegrees, + videoTrack.naturalSize.width, videoTrack.naturalSize.height); + if (rotationDegrees == 90) { + NSLog(@"Setting transform tx"); + transform.tx = videoTrack.naturalSize.height; + transform.ty = 0; + } else if (rotationDegrees == 270) { + NSLog(@"Setting transform ty"); + transform.tx = 0; + transform.ty = videoTrack.naturalSize.width; + } + } + return transform; +} + +- (instancetype)initWithPlayerItem:(AVPlayerItem*)item frameUpdater:(FLTFrameUpdater*)frameUpdater { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + _isInitialized = false; + _isPlaying = false; + _disposed = false; + AVAsset* asset = [item asset]; void (^assetCompletionHandler)(void) = ^{ if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) { @@ -115,9 +185,19 @@ - (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpda if (_disposed) return; if ([videoTrack statusOfValueForKey:@"preferredTransform" error:nil] == AVKeyValueStatusLoaded) { - dispatch_async(dispatch_get_main_queue(), ^{ - [_player replaceCurrentItemWithPlayerItem:item]; - }); + CGSize size = videoTrack.naturalSize; + + // Rotate the video by using a videoComposition and the preferredTransform + _preferredTransform = [self fixTransform:videoTrack]; + // Note: + // https://developer.apple.com/documentation/avfoundation/avplayeritem/1388818-videocomposition + // Video composition can only be used with file-based media and is not supported for + // use with media served using HTTP Live Streaming. + AVMutableVideoComposition* videoComposition = + [self getVideoCompositionWithTransform:_preferredTransform + withAsset:asset + withVideoTrack:videoTrack]; + item.videoComposition = videoComposition; } }; [videoTrack loadValuesAsynchronouslyForKeys:@[ @"preferredTransform" ] @@ -125,11 +205,18 @@ - (instancetype)initWithURL:(NSURL*)url frameUpdater:(FLTFrameUpdater*)frameUpda } } }; + + _player = [AVPlayer playerWithPlayerItem:item]; + _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; + + CGSize size = item.presentationSize; + + [self createVideoOutputAndDisplayLink:frameUpdater]; + + [self addObservers:item]; + [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] completionHandler:assetCompletionHandler]; - _displayLink = [CADisplayLink displayLinkWithTarget:frameUpdater - selector:@selector(onDisplayLink:)]; - [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; - _displayLink.paused = YES; + return self; } @@ -198,14 +285,28 @@ - (void)updatePlayingState { _displayLink.paused = !_isPlaying; } +static inline CGFloat radiansToDegrees(CGFloat radians) { + // Input range [-pi, pi] or [-180, 180] + CGFloat degrees = GLKMathRadiansToDegrees(radians); + if (degrees < 0) { + // Convert -90 to 270 and -180 to 180 + return degrees + 360; + } + // Output degrees in between [0, 360[ + return degrees; +}; + - (void)sendInitialized { if (_eventSink && _isInitialized) { CGSize size = [self.player currentItem].presentationSize; + CGFloat width = size.width; + CGFloat height = size.height; + _eventSink(@{ @"event" : @"initialized", @"duration" : @([self duration]), - @"width" : @(size.width), - @"height" : @(size.height), + @"width" : @(width), + @"height" : @(height) }); } } @@ -259,6 +360,11 @@ - (FlutterError* _Nullable)onCancelWithArguments:(id _Nullable)arguments { - (FlutterError* _Nullable)onListenWithArguments:(id _Nullable)arguments eventSink:(nonnull FlutterEventSink)events { _eventSink = events; + // TODO(@recastrodiaz): remove the line below when the race condition is resolved: + // https://github.com/flutter/flutter/issues/21483 + // This line ensures the 'initialized' event is sent when the event + // 'AVPlayerItemStatusReadyToPlay' fires before _eventSink is set (this function + // onListenWithArguments is called) [self sendInitialized]; return nil; } @@ -313,6 +419,21 @@ - (instancetype)initWithRegistrar:(NSObject*)registrar { return self; } +- (void)onPlayerSetup:(FLTVideoPlayer*)player + frameUpdater:(FLTFrameUpdater*)frameUpdater + result:(FlutterResult)result { + int64_t textureId = [_registry registerTexture:player]; + frameUpdater.textureId = textureId; + FlutterEventChannel* eventChannel = [FlutterEventChannel + eventChannelWithName:[NSString stringWithFormat:@"flutter.io/videoPlayer/videoEvents%lld", + textureId] + binaryMessenger:_messenger]; + [eventChannel setStreamHandler:player]; + player.eventChannel = eventChannel; + _players[@(textureId)] = player; + result(@{@"textureId" : @(textureId)}); +} + - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { if ([@"init" isEqualToString:call.method]) { // Allow audio playback when the Ring/Silent switch is set to silent @@ -327,32 +448,27 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([@"create" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; FLTFrameUpdater* frameUpdater = [[FLTFrameUpdater alloc] initWithRegistry:_registry]; - NSString* dataSource = argsMap[@"asset"]; + NSString* assetArg = argsMap[@"asset"]; + NSString* uriArg = argsMap[@"uri"]; FLTVideoPlayer* player; - if (dataSource) { + if (assetArg) { NSString* assetPath; NSString* package = argsMap[@"package"]; if (![package isEqual:[NSNull null]]) { - assetPath = [_registrar lookupKeyForAsset:dataSource fromPackage:package]; + assetPath = [_registrar lookupKeyForAsset:assetArg fromPackage:package]; } else { - assetPath = [_registrar lookupKeyForAsset:dataSource]; + assetPath = [_registrar lookupKeyForAsset:assetArg]; } player = [[FLTVideoPlayer alloc] initWithAsset:assetPath frameUpdater:frameUpdater]; - } else { - dataSource = argsMap[@"uri"]; - player = [[FLTVideoPlayer alloc] initWithURL:[NSURL URLWithString:dataSource] + [self onPlayerSetup:player frameUpdater:frameUpdater result:result]; + } else if (uriArg) { + player = [[FLTVideoPlayer alloc] initWithURL:[NSURL URLWithString:uriArg] frameUpdater:frameUpdater]; + [self onPlayerSetup:player frameUpdater:frameUpdater result:result]; + } else { + result(FlutterMethodNotImplemented); } - int64_t textureId = [_registry registerTexture:player]; - frameUpdater.textureId = textureId; - FlutterEventChannel* eventChannel = [FlutterEventChannel - eventChannelWithName:[NSString stringWithFormat:@"flutter.io/videoPlayer/videoEvents%lld", - textureId] - binaryMessenger:_messenger]; - [eventChannel setStreamHandler:player]; - player.eventChannel = eventChannel; - _players[@(textureId)] = player; - result(@{@"textureId" : @(textureId)}); + } else { NSDictionary* argsMap = call.arguments; int64_t textureId = ((NSNumber*)argsMap[@"textureId"]).unsignedIntegerValue; diff --git a/packages/video_player/lib/video_player.dart b/packages/video_player/lib/video_player.dart index 46477e1e4096..441057de32b6 100644 --- a/packages/video_player/lib/video_player.dart +++ b/packages/video_player/lib/video_player.dart @@ -90,7 +90,7 @@ class VideoPlayerValue { bool get initialized => duration != null; bool get hasError => errorDescription != null; - double get aspectRatio => size.width / size.height; + double get aspectRatio => size != null ? size.width / size.height : 1.0; VideoPlayerValue copyWith({ Duration duration, @@ -616,6 +616,7 @@ class _VideoProgressIndicatorState extends State { VoidCallback listener; VideoPlayerController get controller => widget.controller; + VideoProgressColors get colors => widget.colors; @override diff --git a/packages/video_player/pubspec.yaml b/packages/video_player/pubspec.yaml index 73d2890bb61b..bdc0da7c0033 100644 --- a/packages/video_player/pubspec.yaml +++ b/packages/video_player/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player description: Flutter plugin for displaying inline video with other Flutter widgets on Android and iOS. author: Flutter Team -version: 0.8.0 +version: 0.9.0 homepage: https://github.com/flutter/plugins/tree/master/packages/video_player flutter: