Skip to content

Commit

Permalink
[video_player] Add macOS support (#4982)
Browse files Browse the repository at this point in the history
Adds macOS support to `video_player`, sharing almost all of the code with iOS.

Notes about changes at a high level:
- macOS does not have `CADisplayLink` (prior to 14, and even there without all the functionality we need), so this adds macOS compilation branches that use the lower-level `CVDisplayLink` instead. Per the TODO, this code should be extracted later to reduce `ifdef`s in what is already a complicated file.
- Adds KVO unregistration on `dealloc` if it wasn't done in `dispose`, since unit tests were crashing on macOS with that.
- Temporarily ifdef's out `publish:` for macOS, with a TODO to re-enable it after the next stable.

Most of flutter/flutter#41688
Once this lands, the app-facing package will be updated to endorse it for macOS.
  • Loading branch information
stuartmorgan authored Sep 28, 2023
1 parent 79461c2 commit c070b0a
Show file tree
Hide file tree
Showing 44 changed files with 1,864 additions and 152 deletions.
4 changes: 4 additions & 0 deletions packages/video_player/video_player_avfoundation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.5.0

* Adds support for macOS.

## 2.4.11

* Updates Pigeon.
Expand Down
2 changes: 1 addition & 1 deletion packages/video_player/video_player_avfoundation/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# video\_player\_avfoundation

The iOS implementation of [`video_player`][1].
The iOS and macOS implementation of [`video_player`][1].

## Usage

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// 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 <FlutterMacOS/FlutterMacOS.h>
#else
#import <Flutter/Flutter.h>
#endif

@interface FVPVideoPlayerPlugin : NSObject <FlutterPlugin>
- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
@interface FVPFrameUpdater : NSObject
@property(nonatomic) int64_t textureId;
@property(nonatomic, weak, readonly) NSObject<FlutterTextureRegistry> *registry;
// The output that this updater is managing.
@property(nonatomic, weak) AVPlayerItemVideoOutput *videoOutput;
#if TARGET_OS_IOS
- (void)onDisplayLink:(CADisplayLink *)link;
#endif
@end

@implementation FVPFrameUpdater
Expand All @@ -29,11 +33,34 @@ - (FVPFrameUpdater *)initWithRegistry:(NSObject<FlutterTextureRegistry> *)regist
return self;
}

#if TARGET_OS_IOS
- (void)onDisplayLink:(CADisplayLink *)link {
// TODO(stuartmorgan): Investigate switching this to displayLinkFired; iOS may also benefit from
// the availability check there.
[_registry textureFrameAvailable:_textureId];
}
#endif

- (void)displayLinkFired {
// Only report a new frame if one is actually available.
CMTime outputItemTime = [self.videoOutput itemTimeForHostTime:CACurrentMediaTime()];
if ([self.videoOutput hasNewPixelBufferForItemTime:outputItemTime]) {
[_registry textureFrameAvailable:_textureId];
}
}
@end

#if TARGET_OS_OSX
static CVReturn DisplayLinkCallback(CVDisplayLinkRef displayLink, const CVTimeStamp *now,
const CVTimeStamp *outputTime, CVOptionFlags flagsIn,
CVOptionFlags *flagsOut, void *displayLinkSource) {
// Trigger the main-thread dispatch queue, to drive a frame update check.
__weak dispatch_source_t source = (__bridge dispatch_source_t)displayLinkSource;
dispatch_source_merge_data(source, 1);
return kCVReturnSuccess;
}
#endif

@interface FVPDefaultPlayerFactory : NSObject <FVPPlayerFactory>
@end

Expand All @@ -53,18 +80,33 @@ @interface FVPVideoPlayer : NSObject <FlutterTexture, FlutterStreamHandler>
// 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) CADisplayLink *displayLink;
// The plugin registrar, to obtain view information from.
@property(nonatomic, weak) NSObject<FlutterPluginRegistrar> *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;
// TODO(stuartmorgan): Extract and abstract the display link to remove all the display-link-related
// ifdefs from this file.
#if TARGET_OS_OSX
// The display link to trigger frame reads from the video player.
@property(nonatomic, assign) CVDisplayLinkRef displayLink;
// A dispatch source to move display link callbacks to the main thread.
@property(nonatomic, strong) dispatch_source_t displayLinkSource;
#else
@property(nonatomic) CADisplayLink *displayLink;
#endif

- (instancetype)initWithURL:(NSURL *)url
frameUpdater:(FVPFrameUpdater *)frameUpdater
httpHeaders:(nonnull NSDictionary<NSString *, NSString *> *)headers
playerFactory:(id<FVPPlayerFactory>)playerFactory;
playerFactory:(id<FVPPlayerFactory>)playerFactory
registrar:(NSObject<FlutterPluginRegistrar> *)registrar;
@end

static void *timeRangeContext = &timeRangeContext;
Expand All @@ -77,12 +119,27 @@ - (instancetype)initWithURL:(NSURL *)url
@implementation FVPVideoPlayer
- (instancetype)initWithAsset:(NSString *)asset
frameUpdater:(FVPFrameUpdater *)frameUpdater
playerFactory:(id<FVPPlayerFactory>)playerFactory {
playerFactory:(id<FVPPlayerFactory>)playerFactory
registrar:(NSObject<FlutterPluginRegistrar> *)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
httpHeaders:@{}
playerFactory:playerFactory];
playerFactory:playerFactory
registrar:registrar];
}

- (void)dealloc {
if (!_disposed) {
[self removeKeyValueObservers];
}
}

- (void)addObserversForItem:(AVPlayerItem *)item player:(AVPlayer *)player {
Expand Down Expand Up @@ -153,15 +210,6 @@ NS_INLINE CGFloat radiansToDegrees(CGFloat radians) {
return degrees;
};

NS_INLINE UIViewController *rootViewController(void) {
#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
return UIApplication.sharedApplication.keyWindow.rootViewController;
#pragma clang diagnostic pop
}

- (AVMutableVideoComposition *)getVideoCompositionWithTransform:(CGAffineTransform)transform
withAsset:(AVAsset *)asset
withVideoTrack:(AVAssetTrack *)videoTrack {
Expand Down Expand Up @@ -202,31 +250,55 @@ - (void)createVideoOutputAndDisplayLink:(FVPFrameUpdater *)frameUpdater {
};
_videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:pixBuffAttributes];

#if TARGET_OS_OSX
frameUpdater.videoOutput = _videoOutput;
// Create and start the main-thread dispatch queue to drive frameUpdater.
self.displayLinkSource =
dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());
dispatch_source_set_event_handler(self.displayLinkSource, ^() {
@autoreleasepool {
[frameUpdater displayLinkFired];
}
});
dispatch_resume(self.displayLinkSource);
if (CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink) == kCVReturnSuccess) {
CVDisplayLinkSetOutputCallback(_displayLink, &DisplayLinkCallback,
(__bridge void *)(self.displayLinkSource));
}
#else
_displayLink = [CADisplayLink displayLinkWithTarget:frameUpdater
selector:@selector(onDisplayLink:)];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
_displayLink.paused = YES;
#endif
}

- (instancetype)initWithURL:(NSURL *)url
frameUpdater:(FVPFrameUpdater *)frameUpdater
httpHeaders:(nonnull NSDictionary<NSString *, NSString *> *)headers
playerFactory:(id<FVPPlayerFactory>)playerFactory {
playerFactory:(id<FVPPlayerFactory>)playerFactory
registrar:(NSObject<FlutterPluginRegistrar> *)registrar {
NSDictionary<NSString *, id> *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 playerFactory:playerFactory];
return [self initWithPlayerItem:item
frameUpdater:frameUpdater
playerFactory:playerFactory
registrar:registrar];
}

- (instancetype)initWithPlayerItem:(AVPlayerItem *)item
frameUpdater:(FVPFrameUpdater *)frameUpdater
playerFactory:(id<FVPPlayerFactory>)playerFactory {
playerFactory:(id<FVPPlayerFactory>)playerFactory
registrar:(NSObject<FlutterPluginRegistrar> *)registrar {
self = [super init];
NSAssert(self, @"super init cannot be nil");

_registrar = registrar;

AVAsset *asset = [item asset];
void (^assetCompletionHandler)(void) = ^{
if ([asset statusOfValueForKey:@"tracks" error:nil] == AVKeyValueStatusLoaded) {
Expand Down Expand Up @@ -265,7 +337,7 @@ - (instancetype)initWithPlayerItem:(AVPlayerItem *)item
// 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];
[rootViewController().view.layer addSublayer:_playerLayer];
[self.flutterViewLayer addSublayer:_playerLayer];

[self createVideoOutputAndDisplayLink:frameUpdater];

Expand Down Expand Up @@ -350,7 +422,23 @@ - (void)updatePlayingState {
} else {
[_player pause];
}
#if TARGET_OS_OSX
if (_displayLink) {
if (_isPlaying) {
NSScreen *screen = self.registrar.view.window.screen;
if (screen) {
CGDirectDisplayID viewDisplayID =
(CGDirectDisplayID)[screen.deviceDescription[@"NSScreenNumber"] unsignedIntegerValue];
CVDisplayLinkSetCurrentCGDisplay(_displayLink, viewDisplayID);
}
CVDisplayLinkStart(_displayLink);
} else {
CVDisplayLinkStop(_displayLink);
}
}
#else
_displayLink.paused = !_isPlaying;
#endif
}

- (void)setupEventSinkIfReadyToPlay {
Expand Down Expand Up @@ -515,14 +603,17 @@ - (void)disposeSansEventChannel {

_disposed = YES;
[_playerLayer removeFromSuperlayer];
#if TARGET_OS_OSX
if (_displayLink) {
CVDisplayLinkStop(_displayLink);
CVDisplayLinkRelease(_displayLink);
_displayLink = NULL;
}
dispatch_source_cancel(_displayLinkSource);
#else
[_displayLink invalidate];
AVPlayerItem *currentItem = self.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"];
[self.player removeObserver:self forKeyPath:@"rate"];
#endif
[self removeKeyValueObservers];

[self.player replaceCurrentItemWithPlayerItem:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self];
Expand All @@ -533,6 +624,33 @@ - (void)dispose {
[_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 () <FVPAVFoundationVideoPlayerApi>
Expand All @@ -547,7 +665,11 @@ @interface FVPVideoPlayerPlugin () <FVPAVFoundationVideoPlayerApi>
@implementation FVPVideoPlayerPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
FVPVideoPlayerPlugin *instance = [[FVPVideoPlayerPlugin alloc] initWithRegistrar:registrar];
#if !TARGET_OS_OSX
// TODO(stuartmorgan): Remove the ifdef once >3.13 reaches stable. See
// https://github.com/flutter/flutter/issues/135320
[registrar publish:instance];
#endif
FVPAVFoundationVideoPlayerApiSetup(registrar.messenger, instance);
}

Expand Down Expand Up @@ -592,8 +714,10 @@ - (FVPTextureMessage *)onPlayerSetup:(FVPVideoPlayer *)player
}

- (void)initialize:(FlutterError *__autoreleasing *)error {
#if TARGET_OS_IOS
// Allow audio playback when the Ring/Silent switch is set to silent
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil];
#endif

[self.playersByTextureId
enumerateKeysAndObjectsUsingBlock:^(NSNumber *textureId, FVPVideoPlayer *player, BOOL *stop) {
Expand All @@ -616,7 +740,8 @@ - (FVPTextureMessage *)create:(FVPCreateMessage *)input error:(FlutterError **)e
@try {
player = [[FVPVideoPlayer alloc] initWithAsset:assetPath
frameUpdater:frameUpdater
playerFactory:_playerFactory];
playerFactory:_playerFactory
registrar:self.registrar];
return [self onPlayerSetup:player frameUpdater:frameUpdater];
} @catch (NSException *exception) {
*error = [FlutterError errorWithCode:@"video_player" message:exception.reason details:nil];
Expand All @@ -626,7 +751,8 @@ - (FVPTextureMessage *)create:(FVPCreateMessage *)input error:(FlutterError **)e
player = [[FVPVideoPlayer alloc] initWithURL:[NSURL URLWithString:input.uri]
frameUpdater:frameUpdater
httpHeaders:input.httpHeaders
playerFactory:_playerFactory];
playerFactory:_playerFactory
registrar:self.registrar];
return [self onPlayerSetup:player frameUpdater:frameUpdater];
} else {
*error = [FlutterError errorWithCode:@"video_player" message:@"not implemented" details:nil];
Expand Down Expand Up @@ -702,13 +828,17 @@ - (void)pause:(FVPTextureMessage *)input error:(FlutterError **)error {

- (void)setMixWithOthers:(FVPMixWithOthersMessage *)input
error:(FlutterError *_Nullable __autoreleasing *)error {
#if TARGET_OS_OSX
// AVAudioSession doesn't exist on macOS, and audio always mixes, so just no-op.
#else
if (input.mixWithOthers.boolValue) {
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionMixWithOthers
error:nil];
} else {
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil];
}
#endif
}

@end
Loading

0 comments on commit c070b0a

Please sign in to comment.