diff --git a/packages/video_player/video_player_avfoundation/CHANGELOG.md b/packages/video_player/video_player_avfoundation/CHANGELOG.md index fe47d79e6a6d..55d1435a1188 100644 --- a/packages/video_player/video_player_avfoundation/CHANGELOG.md +++ b/packages/video_player/video_player_avfoundation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.6.4 + +* Refactors native code structure. + ## 2.6.3 * Fixes VideoPlayerController.initialize() future never resolving with invalid video file. diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPAVFactory.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPAVFactory.m new file mode 100644 index 000000000000..fdbf0132e62a --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPAVFactory.m @@ -0,0 +1,18 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "./include/video_player_avfoundation/FVPAVFactory.h" + +#import + +@implementation FVPDefaultAVFactory +- (AVPlayer *)playerWithPlayerItem:(AVPlayerItem *)playerItem { + return [AVPlayer playerWithPlayerItem:playerItem]; +} + +- (AVPlayerItemVideoOutput *)videoOutputWithPixelBufferAttributes: + (NSDictionary *)attributes { + return [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:attributes]; +} +@end diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPFrameUpdater.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPFrameUpdater.m new file mode 100644 index 000000000000..435699879f1b --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPFrameUpdater.m @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "./include/video_player_avfoundation/FVPFrameUpdater.h" + +/// FVPFrameUpdater is responsible for notifying the Flutter texture registry +/// when a new video frame is available. +@interface FVPFrameUpdater () +/// The Flutter texture registry used to notify about new frames. +@property(nonatomic, weak, readonly) NSObject *registry; +@end + +@implementation FVPFrameUpdater +- (FVPFrameUpdater *)initWithRegistry:(NSObject *)registry { + NSAssert(self, @"super init cannot be nil"); + if (self == nil) return nil; + _registry = registry; + _lastKnownAvailableTime = kCMTimeInvalid; + return self; +} + +- (void)displayLinkFired { + // Only report a new frame if one is actually available. + CMTime outputItemTime = [self.videoOutput itemTimeForHostTime:CACurrentMediaTime()]; + if ([self.videoOutput hasNewPixelBufferForItemTime:outputItemTime]) { + _lastKnownAvailableTime = outputItemTime; + [_registry textureFrameAvailable:_textureId]; + } +} +@end diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m new file mode 100644 index 000000000000..31573da4039f --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m @@ -0,0 +1,606 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "./include/video_player_avfoundation/FVPVideoPlayer.h" +#import "./include/video_player_avfoundation/FVPVideoPlayer_Test.h" + +#import + +#import "./include/video_player_avfoundation/AVAssetTrackUtils.h" + +static void *timeRangeContext = &timeRangeContext; +static void *statusContext = &statusContext; +static void *presentationSizeContext = &presentationSizeContext; +static void *durationContext = &durationContext; +static void *playbackLikelyToKeepUpContext = &playbackLikelyToKeepUpContext; +static void *rateContext = &rateContext; + +@interface FVPVideoPlayer () +/// The AVPlayerItemVideoOutput associated with this video player. +@property(nonatomic, readonly) AVPlayerItemVideoOutput *videoOutput; +/// The plugin registrar, to obtain view information from. +@property(nonatomic, readonly) NSObject *registrar; +/// The CALayer associated with the Flutter view this plugin is associated with, if any. +@property(nonatomic, readonly) CALayer *flutterViewLayer; +/// The Flutter event sink used to send events to the Flutter engine. +@property(nonatomic) FlutterEventSink eventSink; +/// The preferred transform for the video. It can be used to handle the rotation of the video. +@property(nonatomic) CGAffineTransform preferredTransform; +/// Indicates whether the video player is currently playing. +@property(nonatomic, readonly) BOOL isPlaying; +/// Indicates whether the video player has been initialized. +@property(nonatomic, readonly) BOOL isInitialized; +/// The updater that drives callbacks to the engine to indicate that a new frame is ready. +@property(nonatomic) FVPFrameUpdater *frameUpdater; +/// The display link that drives frameUpdater. +@property(nonatomic) FVPDisplayLink *displayLink; +/// Whether a new frame needs to be provided to the engine regardless of the current play/pause +/// state (e.g., after a seek while paused). If YES, the display link should continue to run until +/// the next frame is successfully provided. +@property(nonatomic, assign) BOOL waitingForFrame; + +/// Updates the playing state of the video player. +- (void)updatePlayingState; +@end + +@implementation FVPVideoPlayer +- (instancetype)initWithAsset:(NSString *)asset + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + avFactory:(id)avFactory + registrar:(NSObject *)registrar { + NSString *path = [[NSBundle mainBundle] pathForResource:asset ofType:nil]; +#if TARGET_OS_OSX + // See https://github.com/flutter/flutter/issues/135302 + // TODO(stuartmorgan): Remove this if the asset APIs are adjusted to work better for macOS. + if (!path) { + path = [NSURL URLWithString:asset relativeToURL:NSBundle.mainBundle.bundleURL].path; + } +#endif + return [self initWithURL:[NSURL fileURLWithPath:path] + frameUpdater:frameUpdater + displayLink:displayLink + httpHeaders:@{} + avFactory:avFactory + registrar:registrar]; +} + +- (instancetype)initWithURL:(NSURL *)url + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + httpHeaders:(nonnull NSDictionary *)headers + avFactory:(id)avFactory + registrar:(NSObject *)registrar { + NSDictionary *options = nil; + if ([headers count] != 0) { + options = @{@"AVURLAssetHTTPHeaderFieldsKey" : headers}; + } + AVURLAsset *urlAsset = [AVURLAsset URLAssetWithURL:url options:options]; + AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset]; + return [self initWithPlayerItem:item + frameUpdater:frameUpdater + displayLink:(FVPDisplayLink *)displayLink + avFactory:avFactory + registrar:registrar]; +} + +- (instancetype)initWithPlayerItem:(AVPlayerItem *)item + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + avFactory:(id)avFactory + registrar:(NSObject *)registrar { + self = [super init]; + NSAssert(self, @"super init cannot be nil"); + + _registrar = registrar; + _frameUpdater = frameUpdater; + + AVAsset *asset = [item asset]; + void (^assetCompletionHandler)(void) = ^{ + if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) { + NSArray *tracks = [asset tracksWithMediaType:AVMediaTypeVideo]; + if ([tracks count] > 0) { + AVAssetTrack *videoTrack = tracks[0]; + void (^trackCompletionHandler)(void) = ^{ + if (self->_disposed) return; + if ([videoTrack statusOfValueForKey:@"preferredTransform" + error:nil] == AVKeyValueStatusLoaded) { + // Rotate the video by using a videoComposition and the preferredTransform + self->_preferredTransform = FVPGetStandardizedTransformForTrack(videoTrack); + // Note: + // https://developer.apple.com/documentation/avfoundation/avplayeritem/1388818-videocomposition + // Video composition can only be used with file-based media and is not supported for + // use with media served using HTTP Live Streaming. + AVMutableVideoComposition *videoComposition = + [self getVideoCompositionWithTransform:self->_preferredTransform + withAsset:asset + withVideoTrack:videoTrack]; + item.videoComposition = videoComposition; + } + }; + [videoTrack loadValuesAsynchronouslyForKeys:@[ @"preferredTransform" ] + completionHandler:trackCompletionHandler]; + } + } + }; + + _player = [avFactory playerWithPlayerItem:item]; + _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; + + // This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16 + // (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some + // video streams (not just iOS 16). (https://github.com/flutter/flutter/issues/109116). An + // invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams + // for issue #1, and restore the correct width and height for issue #2. + _playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; + [self.flutterViewLayer addSublayer:_playerLayer]; + + // Configure output. + _displayLink = displayLink; + NSDictionary *pixBuffAttributes = @{ + (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), + (id)kCVPixelBufferIOSurfacePropertiesKey : @{} + }; + _videoOutput = [avFactory videoOutputWithPixelBufferAttributes:pixBuffAttributes]; + frameUpdater.videoOutput = _videoOutput; + + [self addObserversForItem:item player:_player]; + + [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] completionHandler:assetCompletionHandler]; + + return self; +} + +- (void)dealloc { + if (!_disposed) { + [self removeKeyValueObservers]; + } +} + +- (void)addObserversForItem:(AVPlayerItem *)item player:(AVPlayer *)player { + [item addObserver:self + forKeyPath:@"loadedTimeRanges" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:timeRangeContext]; + [item addObserver:self + forKeyPath:@"status" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:statusContext]; + [item addObserver:self + forKeyPath:@"presentationSize" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:presentationSizeContext]; + [item addObserver:self + forKeyPath:@"duration" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:durationContext]; + [item addObserver:self + forKeyPath:@"playbackLikelyToKeepUp" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:playbackLikelyToKeepUpContext]; + + // Add observer to AVPlayer instead of AVPlayerItem since the AVPlayerItem does not have a "rate" + // property + [player addObserver:self + forKeyPath:@"rate" + options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew + context:rateContext]; + + // Add an observer that will respond to itemDidPlayToEndTime + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(itemDidPlayToEndTime:) + name:AVPlayerItemDidPlayToEndTimeNotification + object:item]; +} + +- (void)itemDidPlayToEndTime:(NSNotification *)notification { + if (_isLooping) { + AVPlayerItem *p = [notification object]; + [p seekToTime:kCMTimeZero completionHandler:nil]; + } else { + if (_eventSink) { + _eventSink(@{@"event" : @"completed"}); + } + } +} + +const int64_t TIME_UNSET = -9223372036854775807; + +NS_INLINE int64_t FVPCMTimeToMillis(CMTime time) { + // When CMTIME_IS_INDEFINITE return a value that matches TIME_UNSET from ExoPlayer2 on Android. + // Fixes https://github.com/flutter/flutter/issues/48670 + if (CMTIME_IS_INDEFINITE(time)) return TIME_UNSET; + if (time.timescale == 0) return 0; + return time.value * 1000 / time.timescale; +} + +NS_INLINE CGFloat radiansToDegrees(CGFloat radians) { + // Input range [-pi, pi] or [-180, 180] + CGFloat degrees = GLKMathRadiansToDegrees((float)radians); + if (degrees < 0) { + // Convert -90 to 270 and -180 to 180 + return degrees + 360; + } + // Output degrees in between [0, 360] + return degrees; +}; + +- (AVMutableVideoComposition *)getVideoCompositionWithTransform:(CGAffineTransform)transform + withAsset:(AVAsset *)asset + withVideoTrack:(AVAssetTrack *)videoTrack { + AVMutableVideoCompositionInstruction *instruction = + [AVMutableVideoCompositionInstruction videoCompositionInstruction]; + instruction.timeRange = CMTimeRangeMake(kCMTimeZero, [asset duration]); + AVMutableVideoCompositionLayerInstruction *layerInstruction = + [AVMutableVideoCompositionLayerInstruction + videoCompositionLayerInstructionWithAssetTrack:videoTrack]; + [layerInstruction setTransform:_preferredTransform atTime:kCMTimeZero]; + + AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition]; + instruction.layerInstructions = @[ layerInstruction ]; + videoComposition.instructions = @[ instruction ]; + + // If in portrait mode, switch the width and height of the video + CGFloat width = videoTrack.naturalSize.width; + CGFloat height = videoTrack.naturalSize.height; + NSInteger rotationDegrees = + (NSInteger)round(radiansToDegrees(atan2(_preferredTransform.b, _preferredTransform.a))); + if (rotationDegrees == 90 || rotationDegrees == 270) { + width = videoTrack.naturalSize.height; + height = videoTrack.naturalSize.width; + } + videoComposition.renderSize = CGSizeMake(width, height); + + // TODO(@recastrodiaz): should we use videoTrack.nominalFrameRate ? + // Currently set at a constant 30 FPS + videoComposition.frameDuration = CMTimeMake(1, 30); + + return videoComposition; +} + +- (void)observeValueForKeyPath:(NSString *)path + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + if (context == timeRangeContext) { + if (_eventSink != nil) { + NSMutableArray *> *values = [[NSMutableArray alloc] init]; + for (NSValue *rangeValue in [object loadedTimeRanges]) { + CMTimeRange range = [rangeValue CMTimeRangeValue]; + int64_t start = FVPCMTimeToMillis(range.start); + [values addObject:@[ @(start), @(start + FVPCMTimeToMillis(range.duration)) ]]; + } + _eventSink(@{@"event" : @"bufferingUpdate", @"values" : values}); + } + } else if (context == statusContext) { + AVPlayerItem *item = (AVPlayerItem *)object; + switch (item.status) { + case AVPlayerItemStatusFailed: + [self sendFailedToLoadVideoEvent]; + break; + case AVPlayerItemStatusUnknown: + break; + case AVPlayerItemStatusReadyToPlay: + [item addOutput:_videoOutput]; + [self setupEventSinkIfReadyToPlay]; + [self updatePlayingState]; + break; + } + } else if (context == presentationSizeContext || context == durationContext) { + AVPlayerItem *item = (AVPlayerItem *)object; + if (item.status == AVPlayerItemStatusReadyToPlay) { + // Due to an apparent bug, when the player item is ready, it still may not have determined + // its presentation size or duration. When these properties are finally set, re-check if + // all required properties and instantiate the event sink if it is not already set up. + [self setupEventSinkIfReadyToPlay]; + [self updatePlayingState]; + } + } else if (context == playbackLikelyToKeepUpContext) { + [self updatePlayingState]; + if ([[_player currentItem] isPlaybackLikelyToKeepUp]) { + if (_eventSink != nil) { + _eventSink(@{@"event" : @"bufferingEnd"}); + } + } else { + if (_eventSink != nil) { + _eventSink(@{@"event" : @"bufferingStart"}); + } + } + } else if (context == rateContext) { + // Important: Make sure to cast the object to AVPlayer when observing the rate property, + // as it is not available in AVPlayerItem. + AVPlayer *player = (AVPlayer *)object; + if (_eventSink != nil) { + _eventSink( + @{@"event" : @"isPlayingStateUpdate", @"isPlaying" : player.rate > 0 ? @YES : @NO}); + } + } +} + +- (void)updatePlayingState { + if (!_isInitialized) { + return; + } + if (_isPlaying) { + [_player play]; + } else { + [_player pause]; + } + // If the texture is still waiting for an expected frame, the display link needs to keep + // running until it arrives regardless of the play/pause state. + _displayLink.running = _isPlaying || self.waitingForFrame; +} + +- (void)sendFailedToLoadVideoEvent { + if (_eventSink == nil) { + return; + } + // Prefer more detailed error information from tracks loading. + NSError *error; + if ([self.player.currentItem.asset statusOfValueForKey:@"tracks" + error:&error] != AVKeyValueStatusFailed) { + error = self.player.currentItem.error; + } + __block NSMutableOrderedSet *details = + [NSMutableOrderedSet orderedSetWithObject:@"Failed to load video"]; + void (^add)(NSString *) = ^(NSString *detail) { + if (detail != nil) { + [details addObject:detail]; + } + }; + NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; + add(error.localizedDescription); + add(error.localizedFailureReason); + add(underlyingError.localizedDescription); + add(underlyingError.localizedFailureReason); + NSString *message = [details.array componentsJoinedByString:@": "]; + _eventSink([FlutterError errorWithCode:@"VideoError" message:message details:nil]); +} + +- (void)setupEventSinkIfReadyToPlay { + if (_eventSink && !_isInitialized) { + AVPlayerItem *currentItem = self.player.currentItem; + CGSize size = currentItem.presentationSize; + CGFloat width = size.width; + CGFloat height = size.height; + + // Wait until tracks are loaded to check duration or if there are any videos. + AVAsset *asset = currentItem.asset; + if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) { + void (^trackCompletionHandler)(void) = ^{ + if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) { + // Cancelled, or something failed. + return; + } + // This completion block will run on an AVFoundation background queue. + // Hop back to the main thread to set up event sink. + [self performSelector:_cmd onThread:NSThread.mainThread withObject:self waitUntilDone:NO]; + }; + [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] + completionHandler:trackCompletionHandler]; + return; + } + + BOOL hasVideoTracks = [asset tracksWithMediaType:AVMediaTypeVideo].count != 0; + BOOL hasNoTracks = asset.tracks.count == 0; + + // The player has not yet initialized when it has no size, unless it is an audio-only track. + // HLS m3u8 video files never load any tracks, and are also not yet initialized until they have + // a size. + if ((hasVideoTracks || hasNoTracks) && height == CGSizeZero.height && + width == CGSizeZero.width) { + return; + } + // The player may be initialized but still needs to determine the duration. + int64_t duration = [self duration]; + if (duration == 0) { + return; + } + + _isInitialized = YES; + _eventSink(@{ + @"event" : @"initialized", + @"duration" : @(duration), + @"width" : @(width), + @"height" : @(height) + }); + } +} + +- (void)play { + _isPlaying = YES; + [self updatePlayingState]; +} + +- (void)pause { + _isPlaying = NO; + [self updatePlayingState]; +} + +- (int64_t)position { + return FVPCMTimeToMillis([_player currentTime]); +} + +- (int64_t)duration { + // Note: https://openradar.appspot.com/radar?id=4968600712511488 + // `[AVPlayerItem duration]` can be `kCMTimeIndefinite`, + // use `[[AVPlayerItem asset] duration]` instead. + return FVPCMTimeToMillis([[[_player currentItem] asset] duration]); +} + +- (void)seekTo:(int64_t)location completionHandler:(void (^)(BOOL))completionHandler { + CMTime previousCMTime = _player.currentTime; + CMTime targetCMTime = CMTimeMake(location, 1000); + CMTimeValue duration = _player.currentItem.asset.duration.value; + // Without adding tolerance when seeking to duration, + // seekToTime will never complete, and this call will hang. + // see issue https://github.com/flutter/flutter/issues/124475. + CMTime tolerance = location == duration ? CMTimeMake(1, 1000) : kCMTimeZero; + [_player seekToTime:targetCMTime + toleranceBefore:tolerance + toleranceAfter:tolerance + completionHandler:^(BOOL completed) { + if (CMTimeCompare(self.player.currentTime, previousCMTime) != 0) { + // Ensure that a frame is drawn once available, even if currently paused. In theory a race + // is possible here where the new frame has already drawn by the time this code runs, and + // the display link stays on indefinitely, but that should be relatively harmless. This + // must use the display link rather than just informing the engine that a new frame is + // available because the seek completing doesn't guarantee that the pixel buffer is + // already available. + [self expectFrame]; + } + + if (completionHandler) { + completionHandler(completed); + } + }]; +} + +- (void)expectFrame { + self.waitingForFrame = YES; + self.displayLink.running = YES; +} + +- (void)setIsLooping:(BOOL)isLooping { + _isLooping = isLooping; +} + +- (void)setVolume:(double)volume { + _player.volume = (float)((volume < 0.0) ? 0.0 : ((volume > 1.0) ? 1.0 : volume)); +} + +- (void)setPlaybackSpeed:(double)speed { + // See https://developer.apple.com/library/archive/qa/qa1772/_index.html for an explanation of + // these checks. + if (speed > 2.0 && !_player.currentItem.canPlayFastForward) { + if (_eventSink != nil) { + _eventSink([FlutterError errorWithCode:@"VideoError" + message:@"Video cannot be fast-forwarded beyond 2.0x" + details:nil]); + } + return; + } + + if (speed < 1.0 && !_player.currentItem.canPlaySlowForward) { + if (_eventSink != nil) { + _eventSink([FlutterError errorWithCode:@"VideoError" + message:@"Video cannot be slow-forwarded" + details:nil]); + } + return; + } + + _player.rate = speed; +} + +- (CVPixelBufferRef)copyPixelBuffer { + CVPixelBufferRef buffer = NULL; + CMTime outputItemTime = [_videoOutput itemTimeForHostTime:CACurrentMediaTime()]; + if ([_videoOutput hasNewPixelBufferForItemTime:outputItemTime]) { + buffer = [_videoOutput copyPixelBufferForItemTime:outputItemTime itemTimeForDisplay:NULL]; + } else { + // If the current time isn't available yet, use the time that was checked when informing the + // engine that a frame was available (if any). + CMTime lastAvailableTime = self.frameUpdater.lastKnownAvailableTime; + if (CMTIME_IS_VALID(lastAvailableTime)) { + buffer = [_videoOutput copyPixelBufferForItemTime:lastAvailableTime itemTimeForDisplay:NULL]; + } + } + + if (self.waitingForFrame && buffer) { + self.waitingForFrame = NO; + // If the display link was only running temporarily to pick up a new frame while the video was + // paused, stop it again. + if (!self.isPlaying) { + self.displayLink.running = NO; + } + } + + return buffer; +} + +- (void)onTextureUnregistered:(NSObject *)texture { + dispatch_async(dispatch_get_main_queue(), ^{ + [self dispose]; + }); +} + +- (FlutterError *_Nullable)onCancelWithArguments:(id _Nullable)arguments { + _eventSink = nil; + return nil; +} + +- (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments + eventSink:(nonnull FlutterEventSink)events { + _eventSink = events; + // TODO(@recastrodiaz): remove the line below when the race condition is resolved: + // https://github.com/flutter/flutter/issues/21483 + // This line ensures the 'initialized' event is sent when the event + // 'AVPlayerItemStatusReadyToPlay' fires before _eventSink is set (this function + // onListenWithArguments is called) + // and also send error in similar case with 'AVPlayerItemStatusFailed' + // https://github.com/flutter/flutter/issues/151475 + // https://github.com/flutter/flutter/issues/147707 + if (self.player.currentItem.status == AVPlayerItemStatusFailed) { + [self sendFailedToLoadVideoEvent]; + return nil; + } + [self setupEventSinkIfReadyToPlay]; + return nil; +} + +/// This method allows you to dispose without touching the event channel. This +/// is useful for the case where the Engine is in the process of deconstruction +/// so the channel is going to die or is already dead. +- (void)disposeSansEventChannel { + // This check prevents the crash caused by removing the KVO observers twice. + // When performing a Hot Restart, the leftover players are disposed once directly + // by [FVPVideoPlayerPlugin initialize:] method and then disposed again by + // [FVPVideoPlayer onTextureUnregistered:] call leading to possible over-release. + if (_disposed) { + return; + } + + _disposed = YES; + [_playerLayer removeFromSuperlayer]; + _displayLink = nil; + [self removeKeyValueObservers]; + + [self.player replaceCurrentItemWithPlayerItem:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)dispose { + [self disposeSansEventChannel]; + [_eventChannel setStreamHandler:nil]; +} + +- (CALayer *)flutterViewLayer { +#if TARGET_OS_OSX + return self.registrar.view.layer; +#else +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + // TODO(hellohuanlin): Provide a non-deprecated codepath. See + // https://github.com/flutter/flutter/issues/104117 + UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController; +#pragma clang diagnostic pop + return root.view.layer; +#endif +} + +/// Removes all key-value observers set up for the player. +/// +/// This is called from dealloc, so must not use any methods on self. +- (void)removeKeyValueObservers { + AVPlayerItem *currentItem = _player.currentItem; + [currentItem removeObserver:self forKeyPath:@"status"]; + [currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"]; + [currentItem removeObserver:self forKeyPath:@"presentationSize"]; + [currentItem removeObserver:self forKeyPath:@"duration"]; + [currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"]; + [_player removeObserver:self forKeyPath:@"rate"]; +} + +@end diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m index 88f7d3722f86..7c70da03d664 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayerPlugin.m @@ -2,61 +2,21 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -#import "FVPVideoPlayerPlugin.h" -#import "FVPVideoPlayerPlugin_Test.h" +#import "./include/video_player_avfoundation/FVPVideoPlayerPlugin.h" +#import "./include/video_player_avfoundation/FVPVideoPlayerPlugin_Test.h" #import -#import -#import "./include/video_player_avfoundation/AVAssetTrackUtils.h" +#import "./include/video_player_avfoundation/FVPAVFactory.h" #import "./include/video_player_avfoundation/FVPDisplayLink.h" +#import "./include/video_player_avfoundation/FVPFrameUpdater.h" +#import "./include/video_player_avfoundation/FVPVideoPlayer.h" #import "./include/video_player_avfoundation/messages.g.h" #if !__has_feature(objc_arc) #error Code Requires ARC. #endif -@interface FVPFrameUpdater : NSObject -@property(nonatomic) int64_t textureId; -@property(nonatomic, weak, readonly) NSObject *registry; -// The output that this updater is managing. -@property(nonatomic, weak) AVPlayerItemVideoOutput *videoOutput; -// The last time that has been validated as avaliable according to hasNewPixelBufferForItemTime:. -@property(nonatomic, assign) CMTime lastKnownAvailableTime; -@end - -@implementation FVPFrameUpdater -- (FVPFrameUpdater *)initWithRegistry:(NSObject *)registry { - NSAssert(self, @"super init cannot be nil"); - if (self == nil) return nil; - _registry = registry; - _lastKnownAvailableTime = kCMTimeInvalid; - return self; -} - -- (void)displayLinkFired { - // Only report a new frame if one is actually available. - CMTime outputItemTime = [self.videoOutput itemTimeForHostTime:CACurrentMediaTime()]; - if ([self.videoOutput hasNewPixelBufferForItemTime:outputItemTime]) { - _lastKnownAvailableTime = outputItemTime; - [_registry textureFrameAvailable:_textureId]; - } -} -@end - -@interface FVPDefaultAVFactory : NSObject -@end - -@implementation FVPDefaultAVFactory -- (AVPlayer *)playerWithPlayerItem:(AVPlayerItem *)playerItem { - return [AVPlayer playerWithPlayerItem:playerItem]; -} -- (AVPlayerItemVideoOutput *)videoOutputWithPixelBufferAttributes: - (NSDictionary *)attributes { - return [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:attributes]; -} -@end - /// Non-test implementation of the diplay link factory. @interface FVPDefaultDisplayLinkFactory : NSObject @end @@ -71,608 +31,6 @@ - (FVPDisplayLink *)displayLinkWithRegistrar:(id)registr #pragma mark - -@interface FVPVideoPlayer () -@property(readonly, nonatomic) AVPlayerItemVideoOutput *videoOutput; -// The plugin registrar, to obtain view information from. -@property(nonatomic, weak) NSObject *registrar; -// The CALayer associated with the Flutter view this plugin is associated with, if any. -@property(nonatomic, readonly) CALayer *flutterViewLayer; -@property(nonatomic) FlutterEventChannel *eventChannel; -@property(nonatomic) FlutterEventSink eventSink; -@property(nonatomic) CGAffineTransform preferredTransform; -@property(nonatomic, readonly) BOOL disposed; -@property(nonatomic, readonly) BOOL isPlaying; -@property(nonatomic) BOOL isLooping; -@property(nonatomic, readonly) BOOL isInitialized; -// The updater that drives callbacks to the engine to indicate that a new frame is ready. -@property(nonatomic) FVPFrameUpdater *frameUpdater; -// The display link that drives frameUpdater. -@property(nonatomic) FVPDisplayLink *displayLink; -// Whether a new frame needs to be provided to the engine regardless of the current play/pause state -// (e.g., after a seek while paused). If YES, the display link should continue to run until the next -// frame is successfully provided. -@property(nonatomic, assign) BOOL waitingForFrame; - -- (instancetype)initWithURL:(NSURL *)url - frameUpdater:(FVPFrameUpdater *)frameUpdater - displayLink:(FVPDisplayLink *)displayLink - httpHeaders:(nonnull NSDictionary *)headers - avFactory:(id)avFactory - registrar:(NSObject *)registrar; - -// Tells the player to run its frame updater until it receives a frame, regardless of the -// play/pause state. -- (void)expectFrame; -@end - -static void *timeRangeContext = &timeRangeContext; -static void *statusContext = &statusContext; -static void *presentationSizeContext = &presentationSizeContext; -static void *durationContext = &durationContext; -static void *playbackLikelyToKeepUpContext = &playbackLikelyToKeepUpContext; -static void *rateContext = &rateContext; - -@implementation FVPVideoPlayer -- (instancetype)initWithAsset:(NSString *)asset - frameUpdater:(FVPFrameUpdater *)frameUpdater - displayLink:(FVPDisplayLink *)displayLink - avFactory:(id)avFactory - registrar:(NSObject *)registrar { - NSString *path = [[NSBundle mainBundle] pathForResource:asset ofType:nil]; -#if TARGET_OS_OSX - // See https://github.com/flutter/flutter/issues/135302 - // TODO(stuartmorgan): Remove this if the asset APIs are adjusted to work better for macOS. - if (!path) { - path = [NSURL URLWithString:asset relativeToURL:NSBundle.mainBundle.bundleURL].path; - } -#endif - return [self initWithURL:[NSURL fileURLWithPath:path] - frameUpdater:frameUpdater - displayLink:displayLink - httpHeaders:@{} - avFactory:avFactory - registrar:registrar]; -} - -- (void)dealloc { - if (!_disposed) { - [self removeKeyValueObservers]; - } -} - -- (void)addObserversForItem:(AVPlayerItem *)item player:(AVPlayer *)player { - [item addObserver:self - forKeyPath:@"loadedTimeRanges" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:timeRangeContext]; - [item addObserver:self - forKeyPath:@"status" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:statusContext]; - [item addObserver:self - forKeyPath:@"presentationSize" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:presentationSizeContext]; - [item addObserver:self - forKeyPath:@"duration" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:durationContext]; - [item addObserver:self - forKeyPath:@"playbackLikelyToKeepUp" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:playbackLikelyToKeepUpContext]; - - // Add observer to AVPlayer instead of AVPlayerItem since the AVPlayerItem does not have a "rate" - // property - [player addObserver:self - forKeyPath:@"rate" - options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew - context:rateContext]; - - // Add an observer that will respond to itemDidPlayToEndTime - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(itemDidPlayToEndTime:) - name:AVPlayerItemDidPlayToEndTimeNotification - object:item]; -} - -- (void)itemDidPlayToEndTime:(NSNotification *)notification { - if (_isLooping) { - AVPlayerItem *p = [notification object]; - [p seekToTime:kCMTimeZero completionHandler:nil]; - } else { - if (_eventSink) { - _eventSink(@{@"event" : @"completed"}); - } - } -} - -const int64_t TIME_UNSET = -9223372036854775807; - -NS_INLINE int64_t FVPCMTimeToMillis(CMTime time) { - // When CMTIME_IS_INDEFINITE return a value that matches TIME_UNSET from ExoPlayer2 on Android. - // Fixes https://github.com/flutter/flutter/issues/48670 - if (CMTIME_IS_INDEFINITE(time)) return TIME_UNSET; - if (time.timescale == 0) return 0; - return time.value * 1000 / time.timescale; -} - -NS_INLINE CGFloat radiansToDegrees(CGFloat radians) { - // Input range [-pi, pi] or [-180, 180] - CGFloat degrees = GLKMathRadiansToDegrees((float)radians); - if (degrees < 0) { - // Convert -90 to 270 and -180 to 180 - return degrees + 360; - } - // Output degrees in between [0, 360] - return degrees; -}; - -- (AVMutableVideoComposition *)getVideoCompositionWithTransform:(CGAffineTransform)transform - withAsset:(AVAsset *)asset - withVideoTrack:(AVAssetTrack *)videoTrack { - AVMutableVideoCompositionInstruction *instruction = - [AVMutableVideoCompositionInstruction videoCompositionInstruction]; - instruction.timeRange = CMTimeRangeMake(kCMTimeZero, [asset duration]); - AVMutableVideoCompositionLayerInstruction *layerInstruction = - [AVMutableVideoCompositionLayerInstruction - videoCompositionLayerInstructionWithAssetTrack:videoTrack]; - [layerInstruction setTransform:_preferredTransform atTime:kCMTimeZero]; - - AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition]; - instruction.layerInstructions = @[ layerInstruction ]; - videoComposition.instructions = @[ instruction ]; - - // If in portrait mode, switch the width and height of the video - CGFloat width = videoTrack.naturalSize.width; - CGFloat height = videoTrack.naturalSize.height; - NSInteger rotationDegrees = - (NSInteger)round(radiansToDegrees(atan2(_preferredTransform.b, _preferredTransform.a))); - if (rotationDegrees == 90 || rotationDegrees == 270) { - width = videoTrack.naturalSize.height; - height = videoTrack.naturalSize.width; - } - videoComposition.renderSize = CGSizeMake(width, height); - - // TODO(@recastrodiaz): should we use videoTrack.nominalFrameRate ? - // Currently set at a constant 30 FPS - videoComposition.frameDuration = CMTimeMake(1, 30); - - return videoComposition; -} - -- (instancetype)initWithURL:(NSURL *)url - frameUpdater:(FVPFrameUpdater *)frameUpdater - displayLink:(FVPDisplayLink *)displayLink - httpHeaders:(nonnull NSDictionary *)headers - avFactory:(id)avFactory - registrar:(NSObject *)registrar { - NSDictionary *options = nil; - if ([headers count] != 0) { - options = @{@"AVURLAssetHTTPHeaderFieldsKey" : headers}; - } - AVURLAsset *urlAsset = [AVURLAsset URLAssetWithURL:url options:options]; - AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset]; - return [self initWithPlayerItem:item - frameUpdater:frameUpdater - displayLink:(FVPDisplayLink *)displayLink - avFactory:avFactory - registrar:registrar]; -} - -- (instancetype)initWithPlayerItem:(AVPlayerItem *)item - frameUpdater:(FVPFrameUpdater *)frameUpdater - displayLink:(FVPDisplayLink *)displayLink - avFactory:(id)avFactory - registrar:(NSObject *)registrar { - self = [super init]; - NSAssert(self, @"super init cannot be nil"); - - _registrar = registrar; - _frameUpdater = frameUpdater; - - AVAsset *asset = [item asset]; - void (^assetCompletionHandler)(void) = ^{ - if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) { - NSArray *tracks = [asset tracksWithMediaType:AVMediaTypeVideo]; - if ([tracks count] > 0) { - AVAssetTrack *videoTrack = tracks[0]; - void (^trackCompletionHandler)(void) = ^{ - if (self->_disposed) return; - if ([videoTrack statusOfValueForKey:@"preferredTransform" - error:nil] == AVKeyValueStatusLoaded) { - // Rotate the video by using a videoComposition and the preferredTransform - self->_preferredTransform = FVPGetStandardizedTransformForTrack(videoTrack); - // Note: - // https://developer.apple.com/documentation/avfoundation/avplayeritem/1388818-videocomposition - // Video composition can only be used with file-based media and is not supported for - // use with media served using HTTP Live Streaming. - AVMutableVideoComposition *videoComposition = - [self getVideoCompositionWithTransform:self->_preferredTransform - withAsset:asset - withVideoTrack:videoTrack]; - item.videoComposition = videoComposition; - } - }; - [videoTrack loadValuesAsynchronouslyForKeys:@[ @"preferredTransform" ] - completionHandler:trackCompletionHandler]; - } - } - }; - - _player = [avFactory playerWithPlayerItem:item]; - _player.actionAtItemEnd = AVPlayerActionAtItemEndNone; - - // This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16 - // (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some - // video streams (not just iOS 16). (https://github.com/flutter/flutter/issues/109116). An - // invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams - // for issue #1, and restore the correct width and height for issue #2. - _playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; - [self.flutterViewLayer addSublayer:_playerLayer]; - - // Configure output. - _displayLink = displayLink; - NSDictionary *pixBuffAttributes = @{ - (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA), - (id)kCVPixelBufferIOSurfacePropertiesKey : @{} - }; - _videoOutput = [avFactory videoOutputWithPixelBufferAttributes:pixBuffAttributes]; - frameUpdater.videoOutput = _videoOutput; - - [self addObserversForItem:item player:_player]; - - [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] completionHandler:assetCompletionHandler]; - - return self; -} - -- (void)observeValueForKeyPath:(NSString *)path - ofObject:(id)object - change:(NSDictionary *)change - context:(void *)context { - if (context == timeRangeContext) { - if (_eventSink != nil) { - NSMutableArray *> *values = [[NSMutableArray alloc] init]; - for (NSValue *rangeValue in [object loadedTimeRanges]) { - CMTimeRange range = [rangeValue CMTimeRangeValue]; - int64_t start = FVPCMTimeToMillis(range.start); - [values addObject:@[ @(start), @(start + FVPCMTimeToMillis(range.duration)) ]]; - } - _eventSink(@{@"event" : @"bufferingUpdate", @"values" : values}); - } - } else if (context == statusContext) { - AVPlayerItem *item = (AVPlayerItem *)object; - switch (item.status) { - case AVPlayerItemStatusFailed: - [self sendFailedToLoadVideoEvent]; - break; - case AVPlayerItemStatusUnknown: - break; - case AVPlayerItemStatusReadyToPlay: - [item addOutput:_videoOutput]; - [self setupEventSinkIfReadyToPlay]; - [self updatePlayingState]; - break; - } - } else if (context == presentationSizeContext || context == durationContext) { - AVPlayerItem *item = (AVPlayerItem *)object; - if (item.status == AVPlayerItemStatusReadyToPlay) { - // Due to an apparent bug, when the player item is ready, it still may not have determined - // its presentation size or duration. When these properties are finally set, re-check if - // all required properties and instantiate the event sink if it is not already set up. - [self setupEventSinkIfReadyToPlay]; - [self updatePlayingState]; - } - } else if (context == playbackLikelyToKeepUpContext) { - [self updatePlayingState]; - if ([[_player currentItem] isPlaybackLikelyToKeepUp]) { - if (_eventSink != nil) { - _eventSink(@{@"event" : @"bufferingEnd"}); - } - } else { - if (_eventSink != nil) { - _eventSink(@{@"event" : @"bufferingStart"}); - } - } - } else if (context == rateContext) { - // Important: Make sure to cast the object to AVPlayer when observing the rate property, - // as it is not available in AVPlayerItem. - AVPlayer *player = (AVPlayer *)object; - if (_eventSink != nil) { - _eventSink( - @{@"event" : @"isPlayingStateUpdate", @"isPlaying" : player.rate > 0 ? @YES : @NO}); - } - } -} - -- (void)updatePlayingState { - if (!_isInitialized) { - return; - } - if (_isPlaying) { - [_player play]; - } else { - [_player pause]; - } - // If the texture is still waiting for an expected frame, the display link needs to keep - // running until it arrives regardless of the play/pause state. - _displayLink.running = _isPlaying || self.waitingForFrame; -} - -- (void)sendFailedToLoadVideoEvent { - if (_eventSink == nil) { - return; - } - // Prefer more detailed error information from tracks loading. - NSError *error; - if ([self.player.currentItem.asset statusOfValueForKey:@"tracks" - error:&error] != AVKeyValueStatusFailed) { - error = self.player.currentItem.error; - } - __block NSMutableOrderedSet *details = - [NSMutableOrderedSet orderedSetWithObject:@"Failed to load video"]; - void (^add)(NSString *) = ^(NSString *detail) { - if (detail != nil) { - [details addObject:detail]; - } - }; - NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; - add(error.localizedDescription); - add(error.localizedFailureReason); - add(underlyingError.localizedDescription); - add(underlyingError.localizedFailureReason); - NSString *message = [details.array componentsJoinedByString:@": "]; - _eventSink([FlutterError errorWithCode:@"VideoError" message:message details:nil]); -} - -- (void)setupEventSinkIfReadyToPlay { - if (_eventSink && !_isInitialized) { - AVPlayerItem *currentItem = self.player.currentItem; - CGSize size = currentItem.presentationSize; - CGFloat width = size.width; - CGFloat height = size.height; - - // Wait until tracks are loaded to check duration or if there are any videos. - AVAsset *asset = currentItem.asset; - if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) { - void (^trackCompletionHandler)(void) = ^{ - if ([asset statusOfValueForKey:@"tracks" error:nil] != AVKeyValueStatusLoaded) { - // Cancelled, or something failed. - return; - } - // This completion block will run on an AVFoundation background queue. - // Hop back to the main thread to set up event sink. - [self performSelector:_cmd onThread:NSThread.mainThread withObject:self waitUntilDone:NO]; - }; - [asset loadValuesAsynchronouslyForKeys:@[ @"tracks" ] - completionHandler:trackCompletionHandler]; - return; - } - - BOOL hasVideoTracks = [asset tracksWithMediaType:AVMediaTypeVideo].count != 0; - BOOL hasNoTracks = asset.tracks.count == 0; - - // The player has not yet initialized when it has no size, unless it is an audio-only track. - // HLS m3u8 video files never load any tracks, and are also not yet initialized until they have - // a size. - if ((hasVideoTracks || hasNoTracks) && height == CGSizeZero.height && - width == CGSizeZero.width) { - return; - } - // The player may be initialized but still needs to determine the duration. - int64_t duration = [self duration]; - if (duration == 0) { - return; - } - - _isInitialized = YES; - _eventSink(@{ - @"event" : @"initialized", - @"duration" : @(duration), - @"width" : @(width), - @"height" : @(height) - }); - } -} - -- (void)play { - _isPlaying = YES; - [self updatePlayingState]; -} - -- (void)pause { - _isPlaying = NO; - [self updatePlayingState]; -} - -- (int64_t)position { - return FVPCMTimeToMillis([_player currentTime]); -} - -- (int64_t)duration { - // Note: https://openradar.appspot.com/radar?id=4968600712511488 - // `[AVPlayerItem duration]` can be `kCMTimeIndefinite`, - // use `[[AVPlayerItem asset] duration]` instead. - return FVPCMTimeToMillis([[[_player currentItem] asset] duration]); -} - -- (void)seekTo:(int64_t)location completionHandler:(void (^)(BOOL))completionHandler { - CMTime previousCMTime = _player.currentTime; - CMTime targetCMTime = CMTimeMake(location, 1000); - CMTimeValue duration = _player.currentItem.asset.duration.value; - // Without adding tolerance when seeking to duration, - // seekToTime will never complete, and this call will hang. - // see issue https://github.com/flutter/flutter/issues/124475. - CMTime tolerance = location == duration ? CMTimeMake(1, 1000) : kCMTimeZero; - [_player seekToTime:targetCMTime - toleranceBefore:tolerance - toleranceAfter:tolerance - completionHandler:^(BOOL completed) { - if (CMTimeCompare(self.player.currentTime, previousCMTime) != 0) { - // Ensure that a frame is drawn once available, even if currently paused. In theory a race - // is possible here where the new frame has already drawn by the time this code runs, and - // the display link stays on indefinitely, but that should be relatively harmless. This - // must use the display link rather than just informing the engine that a new frame is - // available because the seek completing doesn't guarantee that the pixel buffer is - // already available. - [self expectFrame]; - } - - if (completionHandler) { - completionHandler(completed); - } - }]; -} - -- (void)expectFrame { - self.waitingForFrame = YES; - self.displayLink.running = YES; -} - -- (void)setIsLooping:(BOOL)isLooping { - _isLooping = isLooping; -} - -- (void)setVolume:(double)volume { - _player.volume = (float)((volume < 0.0) ? 0.0 : ((volume > 1.0) ? 1.0 : volume)); -} - -- (void)setPlaybackSpeed:(double)speed { - // See https://developer.apple.com/library/archive/qa/qa1772/_index.html for an explanation of - // these checks. - if (speed > 2.0 && !_player.currentItem.canPlayFastForward) { - if (_eventSink != nil) { - _eventSink([FlutterError errorWithCode:@"VideoError" - message:@"Video cannot be fast-forwarded beyond 2.0x" - details:nil]); - } - return; - } - - if (speed < 1.0 && !_player.currentItem.canPlaySlowForward) { - if (_eventSink != nil) { - _eventSink([FlutterError errorWithCode:@"VideoError" - message:@"Video cannot be slow-forwarded" - details:nil]); - } - return; - } - - _player.rate = speed; -} - -- (CVPixelBufferRef)copyPixelBuffer { - CVPixelBufferRef buffer = NULL; - CMTime outputItemTime = [_videoOutput itemTimeForHostTime:CACurrentMediaTime()]; - if ([_videoOutput hasNewPixelBufferForItemTime:outputItemTime]) { - buffer = [_videoOutput copyPixelBufferForItemTime:outputItemTime itemTimeForDisplay:NULL]; - } else { - // If the current time isn't available yet, use the time that was checked when informing the - // engine that a frame was available (if any). - CMTime lastAvailableTime = self.frameUpdater.lastKnownAvailableTime; - if (CMTIME_IS_VALID(lastAvailableTime)) { - buffer = [_videoOutput copyPixelBufferForItemTime:lastAvailableTime itemTimeForDisplay:NULL]; - } - } - - if (self.waitingForFrame && buffer) { - self.waitingForFrame = NO; - // If the display link was only running temporarily to pick up a new frame while the video was - // paused, stop it again. - if (!self.isPlaying) { - self.displayLink.running = NO; - } - } - - return buffer; -} - -- (void)onTextureUnregistered:(NSObject *)texture { - dispatch_async(dispatch_get_main_queue(), ^{ - [self dispose]; - }); -} - -- (FlutterError *_Nullable)onCancelWithArguments:(id _Nullable)arguments { - _eventSink = nil; - return nil; -} - -- (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments - eventSink:(nonnull FlutterEventSink)events { - _eventSink = events; - // TODO(@recastrodiaz): remove the line below when the race condition is resolved: - // https://github.com/flutter/flutter/issues/21483 - // This line ensures the 'initialized' event is sent when the event - // 'AVPlayerItemStatusReadyToPlay' fires before _eventSink is set (this function - // onListenWithArguments is called) - // and also send error in similar case with 'AVPlayerItemStatusFailed' - // https://github.com/flutter/flutter/issues/151475 - // https://github.com/flutter/flutter/issues/147707 - if (self.player.currentItem.status == AVPlayerItemStatusFailed) { - [self sendFailedToLoadVideoEvent]; - return nil; - } - [self setupEventSinkIfReadyToPlay]; - return nil; -} - -/// This method allows you to dispose without touching the event channel. This -/// is useful for the case where the Engine is in the process of deconstruction -/// so the channel is going to die or is already dead. -- (void)disposeSansEventChannel { - // This check prevents the crash caused by removing the KVO observers twice. - // When performing a Hot Restart, the leftover players are disposed once directly - // by [FVPVideoPlayerPlugin initialize:] method and then disposed again by - // [FVPVideoPlayer onTextureUnregistered:] call leading to possible over-release. - if (_disposed) { - return; - } - - _disposed = YES; - [_playerLayer removeFromSuperlayer]; - _displayLink = nil; - [self removeKeyValueObservers]; - - [self.player replaceCurrentItemWithPlayerItem:nil]; - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)dispose { - [self disposeSansEventChannel]; - [_eventChannel setStreamHandler:nil]; -} - -- (CALayer *)flutterViewLayer { -#if TARGET_OS_OSX - return self.registrar.view.layer; -#else -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - // TODO(hellohuanlin): Provide a non-deprecated codepath. See - // https://github.com/flutter/flutter/issues/104117 - UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController; -#pragma clang diagnostic pop - return root.view.layer; -#endif -} - -/// Removes all key-value observers set up for the player. -/// -/// This is called from dealloc, so must not use any methods on self. -- (void)removeKeyValueObservers { - AVPlayerItem *currentItem = _player.currentItem; - [currentItem removeObserver:self forKeyPath:@"status"]; - [currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"]; - [currentItem removeObserver:self forKeyPath:@"presentationSize"]; - [currentItem removeObserver:self forKeyPath:@"duration"]; - [currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"]; - [_player removeObserver:self forKeyPath:@"rate"]; -} - -@end - @interface FVPVideoPlayerPlugin () @property(readonly, weak, nonatomic) NSObject *registry; @property(readonly, weak, nonatomic) NSObject *messenger; diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPAVFactory.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPAVFactory.h new file mode 100644 index 000000000000..84d928cf2309 --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPAVFactory.h @@ -0,0 +1,26 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Protocol for AVFoundation object instance factory. Used for injecting framework objects in +/// tests. +@protocol FVPAVFactory +/// Creates and returns an AVPlayer instance with the specified AVPlayerItem. +@required +- (AVPlayer *)playerWithPlayerItem:(AVPlayerItem *)playerItem; + +/// Creates and returns an AVPlayerItemVideoOutput instance with the specified pixel buffer +/// attributes. +- (AVPlayerItemVideoOutput *)videoOutputWithPixelBufferAttributes: + (NSDictionary *)attributes; +@end + +/// A default implementation of the FVPAVFactory protocol. +@interface FVPDefaultAVFactory : NSObject +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPFrameUpdater.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPFrameUpdater.h new file mode 100644 index 000000000000..1e07ca9f019d --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPFrameUpdater.h @@ -0,0 +1,33 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#if TARGET_OS_OSX +#import +#else +#import +#endif + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// FVPFrameUpdater is responsible for notifying the Flutter texture registry +/// when a new video frame is available. +@interface FVPFrameUpdater : NSObject +/// The texture ID associated with the video output. +@property(nonatomic) int64_t textureId; +/// The output that this updater is managing. +@property(nonatomic, weak) AVPlayerItemVideoOutput *videoOutput; +/// The last time that has been validated as avaliable according to hasNewPixelBufferForItemTime:. +@property(readonly, nonatomic, assign) CMTime lastKnownAvailableTime; + +/// Initializes a new instance of FVPFrameUpdater with the given Flutter texture registry. +- (FVPFrameUpdater *)initWithRegistry:(NSObject *)registry; + +/// Called when the display link fires. Checks if a new frame is available +/// and notifies the Flutter texture registry if a new frame is found. +- (void)displayLinkFired; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h new file mode 100644 index 000000000000..27f95e7692d0 --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer.h @@ -0,0 +1,86 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#if TARGET_OS_OSX +#import +#else +#import +#endif + +#import + +#import "FVPAVFactory.h" +#import "FVPDisplayLink.h" +#import "FVPFrameUpdater.h" + +NS_ASSUME_NONNULL_BEGIN + +/// FVPVideoPlayer is responsible for managing video playback using AVPlayer. +/// It provides methods to control playback, adjust volume, handle seeking, and +/// notify the Flutter engine about new video frames. +@interface FVPVideoPlayer : NSObject +/// The Flutter event channel used to communicate with the Flutter engine. +@property(nonatomic) FlutterEventChannel *eventChannel; +/// Indicates whether the video player has been disposed. +@property(nonatomic, readonly) BOOL disposed; +/// Indicates whether the video player is set to loop. +@property(nonatomic) BOOL isLooping; +/// The current playback position of the video, in milliseconds. +@property(readonly, nonatomic) int64_t position; + +/// Initializes a new instance of FVPVideoPlayer with the given asset, frame updater, display link, +/// AV factory, and registrar. +- (instancetype)initWithAsset:(NSString *)asset + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + avFactory:(id)avFactory + registrar:(NSObject *)registrar; + +/// Initializes a new instance of FVPVideoPlayer with the given URL, frame updater, display link, +/// HTTP headers, AV factory, and registrar. +- (instancetype)initWithURL:(NSURL *)url + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + httpHeaders:(nonnull NSDictionary *)headers + avFactory:(id)avFactory + registrar:(NSObject *)registrar; + +/// Initializes a new instance of FVPVideoPlayer with the given AVPlayerItem, frame updater, display +/// link, AV factory, and registrar. +- (instancetype)initWithPlayerItem:(AVPlayerItem *)item + frameUpdater:(FVPFrameUpdater *)frameUpdater + displayLink:(FVPDisplayLink *)displayLink + avFactory:(id)avFactory + registrar:(NSObject *)registrar; + +/// Disposes the video player and releases any resources it holds. +- (void)dispose; + +/// Disposes the video player without touching the event channel. This +/// is useful for the case where the Engine is in the process of deconstruction +/// so the channel is going to die or is already dead. +- (void)disposeSansEventChannel; + +/// Sets the volume of the video player. +- (void)setVolume:(double)volume; + +/// Sets the playback speed of the video player. +- (void)setPlaybackSpeed:(double)speed; + +/// Starts playing the video. +- (void)play; + +/// Pauses the video. +- (void)pause; + +/// Seeks to the specified location in the video and calls the completion handler when done, if one +/// is supplied. +- (void)seekTo:(int64_t)location completionHandler:(void (^_Nullable)(BOOL))completionHandler; + +/// Tells the player to run its frame updater until it receives a frame, regardless of the +/// play/pause state. +- (void)expectFrame; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayerPlugin_Test.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayerPlugin_Test.h index e34ce16eabcd..e045210e68c7 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayerPlugin_Test.h +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayerPlugin_Test.h @@ -2,21 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -#import "FVPVideoPlayerPlugin.h" - -#import - +#import "FVPAVFactory.h" #import "FVPDisplayLink.h" +#import "FVPVideoPlayer.h" +#import "FVPVideoPlayerPlugin.h" #import "messages.g.h" -// Protocol for AVFoundation object instance factory. Used for injecting framework objects in tests. -@protocol FVPAVFactory -@required -- (AVPlayer *)playerWithPlayerItem:(AVPlayerItem *)playerItem; -- (AVPlayerItemVideoOutput *)videoOutputWithPixelBufferAttributes: - (NSDictionary *)attributes; -@end - // Protocol for an AVPlayer instance factory. Used for injecting display links in tests. @protocol FVPDisplayLinkFactory - (FVPDisplayLink *)displayLinkWithRegistrar:(id)registrar @@ -25,22 +16,6 @@ #pragma mark - -// TODO(stuartmorgan): Move this whole class to its own files. -@interface FVPVideoPlayer : NSObject -@property(readonly, nonatomic) AVPlayer *player; -// This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16 -// (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some video -// streams (not just iOS 16). (https://github.com/flutter/flutter/issues/109116). -// An invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams -// for issue #1, and restore the correct width and height for issue #2. -@property(readonly, nonatomic) AVPlayerLayer *playerLayer; -@property(readonly, nonatomic) int64_t position; - -- (void)onTextureUnregistered:(NSObject *)texture; -@end - -#pragma mark - - @interface FVPVideoPlayerPlugin () @property(readonly, strong, nonatomic) diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer_Test.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer_Test.h new file mode 100644 index 000000000000..d51d7a1a1e08 --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/FVPVideoPlayer_Test.h @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#if TARGET_OS_OSX +#import +#else +#import +#endif + +#import "FVPVideoPlayer.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FVPVideoPlayer () +/// The AVPlayer instance used for video playback. +@property(readonly, nonatomic) AVPlayer *player; +/// The AVPlayerLayer used to display the video content. +/// This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16 +/// (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some +/// video streams (not just iOS 16). (https://github.com/flutter/flutter/issues/109116). An +/// invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams +/// for issue #1, and restore the correct width and height for issue #2. +@property(readonly, nonatomic) AVPlayerLayer *playerLayer; + +/// Called when the texture is unregistered. +/// This method is used to clean up resources associated with the texture. +- (void)onTextureUnregistered:(nullable NSObject *)texture; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/video_player/video_player_avfoundation/pubspec.yaml b/packages/video_player/video_player_avfoundation/pubspec.yaml index 31d615dd8862..233a9b65e86b 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 and macOS implementation of the video_player plugin. repository: https://github.com/flutter/packages/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.6.3 +version: 2.6.4 environment: sdk: ^3.3.0