1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177 |
- #import "AudioPlayer.h"
- #import "AudioSource.h"
- #import "IndexedAudioSource.h"
- #import "UriAudioSource.h"
- #import "ConcatenatingAudioSource.h"
- #import "LoopingAudioSource.h"
- #import "ClippingAudioSource.h"
- #import <AVFoundation/AVFoundation.h>
- #import <stdlib.h>
- #include <TargetConditionals.h>
- // TODO: Check for and report invalid state transitions.
- // TODO: Apply Apple's guidance on seeking: https://developer.apple.com/library/archive/qa/qa1820/_index.html
- @implementation AudioPlayer {
- NSObject<FlutterPluginRegistrar>* _registrar;
- FlutterMethodChannel *_methodChannel;
- FlutterEventChannel *_eventChannel;
- FlutterEventSink _eventSink;
- NSString *_playerId;
- AVQueuePlayer *_player;
- AudioSource *_audioSource;
- NSMutableArray<IndexedAudioSource *> *_indexedAudioSources;
- NSMutableArray<NSNumber *> *_order;
- NSMutableArray<NSNumber *> *_orderInv;
- int _index;
- enum ProcessingState _processingState;
- enum LoopMode _loopMode;
- BOOL _shuffleModeEnabled;
- long long _updateTime;
- int _updatePosition;
- int _lastPosition;
- int _bufferedPosition;
- // Set when the current item hasn't been played yet so we aren't sure whether sufficient audio has been buffered.
- BOOL _bufferUnconfirmed;
- CMTime _seekPos;
- FlutterResult _loadResult;
- FlutterResult _playResult;
- id _timeObserver;
- BOOL _automaticallyWaitsToMinimizeStalling;
- BOOL _configuredSession;
- BOOL _playing;
- float _speed;
- }
- - (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar playerId:(NSString*)idParam configuredSession:(BOOL)configuredSession {
- self = [super init];
- NSAssert(self, @"super init cannot be nil");
- _registrar = registrar;
- _playerId = idParam;
- _configuredSession = configuredSession;
- _methodChannel =
- [FlutterMethodChannel methodChannelWithName:[NSMutableString stringWithFormat:@"com.ryanheise.just_audio.methods.%@", _playerId]
- binaryMessenger:[registrar messenger]];
- _eventChannel =
- [FlutterEventChannel eventChannelWithName:[NSMutableString stringWithFormat:@"com.ryanheise.just_audio.events.%@", _playerId]
- binaryMessenger:[registrar messenger]];
- [_eventChannel setStreamHandler:self];
- _index = 0;
- _processingState = none;
- _loopMode = loopOff;
- _shuffleModeEnabled = NO;
- _player = nil;
- _audioSource = nil;
- _indexedAudioSources = nil;
- _order = nil;
- _orderInv = nil;
- _seekPos = kCMTimeInvalid;
- _timeObserver = 0;
- _updatePosition = 0;
- _updateTime = 0;
- _lastPosition = 0;
- _bufferedPosition = 0;
- _bufferUnconfirmed = NO;
- _playing = NO;
- _loadResult = nil;
- _playResult = nil;
- _automaticallyWaitsToMinimizeStalling = YES;
- _speed = 1.0f;
- __weak __typeof__(self) weakSelf = self;
- [_methodChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
- [weakSelf handleMethodCall:call result:result];
- }];
- return self;
- }
- - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
- NSArray* args = (NSArray*)call.arguments;
- if ([@"load" isEqualToString:call.method]) {
- [self load:args[0] result:result];
- } else if ([@"play" isEqualToString:call.method]) {
- [self play:result];
- } else if ([@"pause" isEqualToString:call.method]) {
- [self pause];
- result(nil);
- } else if ([@"setVolume" isEqualToString:call.method]) {
- [self setVolume:(float)[args[0] doubleValue]];
- result(nil);
- } else if ([@"setSpeed" isEqualToString:call.method]) {
- [self setSpeed:(float)[args[0] doubleValue]];
- result(nil);
- } else if ([@"setLoopMode" isEqualToString:call.method]) {
- [self setLoopMode:[args[0] intValue]];
- result(nil);
- } else if ([@"setShuffleModeEnabled" isEqualToString:call.method]) {
- [self setShuffleModeEnabled:(BOOL)[args[0] boolValue]];
- result(nil);
- } else if ([@"setAutomaticallyWaitsToMinimizeStalling" isEqualToString:call.method]) {
- [self setAutomaticallyWaitsToMinimizeStalling:(BOOL)[args[0] boolValue]];
- result(nil);
- } else if ([@"seek" isEqualToString:call.method]) {
- CMTime position = args[0] == [NSNull null] ? kCMTimePositiveInfinity : CMTimeMake([args[0] intValue], 1000);
- [self seek:position index:args[1] completionHandler:^(BOOL finished) {
- result(nil);
- }];
- result(nil);
- } else if ([@"dispose" isEqualToString:call.method]) {
- [self dispose];
- result(nil);
- } else if ([@"concatenating.add" isEqualToString:call.method]) {
- [self concatenatingAdd:(NSString*)args[0] source:(NSDictionary*)args[1]];
- result(nil);
- } else if ([@"concatenating.insert" isEqualToString:call.method]) {
- [self concatenatingInsert:(NSString*)args[0] index:[args[1] intValue] source:(NSDictionary*)args[2]];
- result(nil);
- } else if ([@"concatenating.addAll" isEqualToString:call.method]) {
- [self concatenatingAddAll:(NSString*)args[0] sources:(NSArray*)args[1]];
- result(nil);
- } else if ([@"concatenating.insertAll" isEqualToString:call.method]) {
- [self concatenatingInsertAll:(NSString*)args[0] index:[args[1] intValue] sources:(NSArray*)args[2]];
- result(nil);
- } else if ([@"concatenating.removeAt" isEqualToString:call.method]) {
- [self concatenatingRemoveAt:(NSString*)args[0] index:[args[1] intValue]];
- result(nil);
- } else if ([@"concatenating.removeRange" isEqualToString:call.method]) {
- [self concatenatingRemoveRange:(NSString*)args[0] start:[args[1] intValue] end:[args[2] intValue]];
- result(nil);
- } else if ([@"concatenating.move" isEqualToString:call.method]) {
- [self concatenatingMove:(NSString*)args[0] currentIndex:[args[1] intValue] newIndex:[args[2] intValue]];
- result(nil);
- } else if ([@"concatenating.clear" isEqualToString:call.method]) {
- [self concatenatingClear:(NSString*)args[0]];
- result(nil);
- } else if ([@"setAndroidAudioAttributes" isEqualToString:call.method]) {
- result(nil);
- } else {
- result(FlutterMethodNotImplemented);
- }
- }
- // Untested
- - (void)concatenatingAdd:(NSString *)catId source:(NSDictionary *)source {
- [self concatenatingInsertAll:catId index:-1 sources:@[source]];
- }
- // Untested
- - (void)concatenatingInsert:(NSString *)catId index:(int)index source:(NSDictionary *)source {
- [self concatenatingInsertAll:catId index:index sources:@[source]];
- }
- // Untested
- - (void)concatenatingAddAll:(NSString *)catId sources:(NSArray *)sources {
- [self concatenatingInsertAll:catId index:-1 sources:sources];
- }
- // Untested
- - (void)concatenatingInsertAll:(NSString *)catId index:(int)index sources:(NSArray *)sources {
- // Find all duplicates of the identified ConcatenatingAudioSource.
- NSMutableArray *matches = [[NSMutableArray alloc] init];
- [_audioSource findById:catId matches:matches];
- // Add each new source to each match.
- for (int i = 0; i < matches.count; i++) {
- ConcatenatingAudioSource *catSource = (ConcatenatingAudioSource *)matches[i];
- int idx = index >= 0 ? index : catSource.count;
- NSMutableArray<AudioSource *> *audioSources = [self decodeAudioSources:sources];
- for (int j = 0; j < audioSources.count; j++) {
- AudioSource *audioSource = audioSources[j];
- [catSource insertSource:audioSource atIndex:(idx + j)];
- }
- }
- // Index the new audio sources.
- _indexedAudioSources = [[NSMutableArray alloc] init];
- [_audioSource buildSequence:_indexedAudioSources treeIndex:0];
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- IndexedAudioSource *audioSource = _indexedAudioSources[i];
- if (!audioSource.isAttached) {
- audioSource.playerItem.audioSource = audioSource;
- [self addItemObservers:audioSource.playerItem];
- }
- }
- [self updateOrder];
- if (_player.currentItem) {
- _index = [self indexForItem:_player.currentItem];
- } else {
- _index = 0;
- }
- [self enqueueFrom:_index];
- // Notify each new IndexedAudioSource that it's been attached to the player.
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- if (!_indexedAudioSources[i].isAttached) {
- [_indexedAudioSources[i] attach:_player];
- }
- }
- [self broadcastPlaybackEvent];
- }
- // Untested
- - (void)concatenatingRemoveAt:(NSString *)catId index:(int)index {
- [self concatenatingRemoveRange:catId start:index end:(index + 1)];
- }
- // Untested
- - (void)concatenatingRemoveRange:(NSString *)catId start:(int)start end:(int)end {
- // Find all duplicates of the identified ConcatenatingAudioSource.
- NSMutableArray *matches = [[NSMutableArray alloc] init];
- [_audioSource findById:catId matches:matches];
- // Remove range from each match.
- for (int i = 0; i < matches.count; i++) {
- ConcatenatingAudioSource *catSource = (ConcatenatingAudioSource *)matches[i];
- int endIndex = end >= 0 ? end : catSource.count;
- [catSource removeSourcesFromIndex:start toIndex:endIndex];
- }
- // Re-index the remaining audio sources.
- NSArray<IndexedAudioSource *> *oldIndexedAudioSources = _indexedAudioSources;
- _indexedAudioSources = [[NSMutableArray alloc] init];
- [_audioSource buildSequence:_indexedAudioSources treeIndex:0];
- for (int i = 0, j = 0; i < _indexedAudioSources.count; i++, j++) {
- IndexedAudioSource *audioSource = _indexedAudioSources[i];
- while (audioSource != oldIndexedAudioSources[j]) {
- [self removeItemObservers:oldIndexedAudioSources[j].playerItem];
- if (j < _index) {
- _index--;
- } else if (j == _index) {
- // The currently playing item was removed.
- }
- j++;
- }
- }
- [self updateOrder];
- if (_index >= _indexedAudioSources.count) _index = _indexedAudioSources.count - 1;
- if (_index < 0) _index = 0;
- [self enqueueFrom:_index];
- [self broadcastPlaybackEvent];
- }
- // Untested
- - (void)concatenatingMove:(NSString *)catId currentIndex:(int)currentIndex newIndex:(int)newIndex {
- // Find all duplicates of the identified ConcatenatingAudioSource.
- NSMutableArray *matches = [[NSMutableArray alloc] init];
- [_audioSource findById:catId matches:matches];
- // Move range within each match.
- for (int i = 0; i < matches.count; i++) {
- ConcatenatingAudioSource *catSource = (ConcatenatingAudioSource *)matches[i];
- [catSource moveSourceFromIndex:currentIndex toIndex:newIndex];
- }
- // Re-index the audio sources.
- _indexedAudioSources = [[NSMutableArray alloc] init];
- [_audioSource buildSequence:_indexedAudioSources treeIndex:0];
- [self updateOrder];
- [self enqueueFrom:[self indexForItem:_player.currentItem]];
- [self broadcastPlaybackEvent];
- }
- // Untested
- - (void)concatenatingClear:(NSString *)catId {
- [self concatenatingRemoveRange:catId start:0 end:-1];
- }
- - (FlutterError*)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)eventSink {
- _eventSink = eventSink;
- return nil;
- }
- - (FlutterError*)onCancelWithArguments:(id)arguments {
- _eventSink = nil;
- return nil;
- }
- - (void)checkForDiscontinuity {
- if (!_eventSink) return;
- if (!_playing || CMTIME_IS_VALID(_seekPos) || _processingState == completed) return;
- int position = [self getCurrentPosition];
- if (_processingState == buffering) {
- if (position > _lastPosition) {
- [self leaveBuffering:@"stall ended"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- }
- } else {
- long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
- long long timeSinceLastUpdate = now - _updateTime;
- long long expectedPosition = _updatePosition + (long long)(timeSinceLastUpdate * _player.rate);
- long long drift = position - expectedPosition;
- //NSLog(@"position: %d, drift: %lld", position, drift);
- // Update if we've drifted or just started observing
- if (_updateTime == 0L) {
- [self broadcastPlaybackEvent];
- } else if (drift < -100) {
- [self enterBuffering:@"stalling"];
- NSLog(@"Drift: %lld", drift);
- [self updatePosition];
- [self broadcastPlaybackEvent];
- }
- }
- _lastPosition = position;
- }
- - (void)enterBuffering:(NSString *)reason {
- NSLog(@"ENTER BUFFERING: %@", reason);
- _processingState = buffering;
- }
- - (void)leaveBuffering:(NSString *)reason {
- NSLog(@"LEAVE BUFFERING: %@", reason);
- _processingState = ready;
- }
- - (void)broadcastPlaybackEvent {
- if (!_eventSink) return;
- _eventSink(@{
- @"processingState": @(_processingState),
- @"updatePosition": @(_updatePosition),
- @"updateTime": @(_updateTime),
- // TODO: buffer position
- @"bufferedPosition": @(_updatePosition),
- // TODO: Icy Metadata
- @"icyMetadata": [NSNull null],
- @"duration": @([self getDuration]),
- @"currentIndex": @(_index),
- });
- }
- - (int)getCurrentPosition {
- if (_processingState == none || _processingState == loading) {
- return 0;
- } else if (CMTIME_IS_VALID(_seekPos)) {
- return (int)(1000 * CMTimeGetSeconds(_seekPos));
- } else if (_indexedAudioSources && _indexedAudioSources.count > 0) {
- int ms = (int)(1000 * CMTimeGetSeconds(_indexedAudioSources[_index].position));
- if (ms < 0) ms = 0;
- return ms;
- } else {
- return 0;
- }
- }
- - (int)getBufferedPosition {
- if (_processingState == none || _processingState == loading) {
- return 0;
- } else if (_indexedAudioSources && _indexedAudioSources.count > 0) {
- int ms = (int)(1000 * CMTimeGetSeconds(_indexedAudioSources[_index].bufferedPosition));
- if (ms < 0) ms = 0;
- return ms;
- } else {
- return 0;
- }
- }
- - (int)getDuration {
- if (_processingState == none) {
- return -1;
- } else if (_indexedAudioSources && _indexedAudioSources.count > 0) {
- int v = (int)(1000 * CMTimeGetSeconds(_indexedAudioSources[_index].duration));
- return v;
- } else {
- return 0;
- }
- }
- - (void)removeItemObservers:(AVPlayerItem *)playerItem {
- [playerItem removeObserver:self forKeyPath:@"status"];
- [playerItem removeObserver:self forKeyPath:@"playbackBufferEmpty"];
- [playerItem removeObserver:self forKeyPath:@"playbackBufferFull"];
- //[playerItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"];
- [[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemDidPlayToEndTimeNotification object:playerItem];
- [[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemFailedToPlayToEndTimeNotification object:playerItem];
- [[NSNotificationCenter defaultCenter] removeObserver:self name:AVPlayerItemPlaybackStalledNotification object:playerItem];
- }
- - (void)addItemObservers:(AVPlayerItem *)playerItem {
- // Get notified when the item is loaded or had an error loading
- [playerItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
- // Get notified of the buffer state
- [playerItem addObserver:self forKeyPath:@"playbackBufferEmpty" options:NSKeyValueObservingOptionNew context:nil];
- [playerItem addObserver:self forKeyPath:@"playbackBufferFull" options:NSKeyValueObservingOptionNew context:nil];
- [playerItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
- //[playerItem addObserver:self forKeyPath:@"playbackLikelyToKeepUp" options:NSKeyValueObservingOptionNew context:nil];
- // Get notified when playback has reached the end
- [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onComplete:) name:AVPlayerItemDidPlayToEndTimeNotification object:playerItem];
- // Get notified when playback stops due to a failure (currently unused)
- [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onFailToComplete:) name:AVPlayerItemFailedToPlayToEndTimeNotification object:playerItem];
- // Get notified when playback stalls (currently unused)
- [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onItemStalled:) name:AVPlayerItemPlaybackStalledNotification object:playerItem];
- }
- - (NSMutableArray *)decodeAudioSources:(NSArray *)data {
- NSMutableArray *array = [[NSMutableArray alloc] init];
- for (int i = 0; i < [data count]; i++) {
- AudioSource *source = [self decodeAudioSource:data[i]];
- [array addObject:source];
- }
- return array;
- }
- - (AudioSource *)decodeAudioSource:(NSDictionary *)data {
- NSString *type = data[@"type"];
- if ([@"progressive" isEqualToString:type]) {
- return [[UriAudioSource alloc] initWithId:data[@"id"] uri:data[@"uri"]];
- } else if ([@"dash" isEqualToString:type]) {
- return [[UriAudioSource alloc] initWithId:data[@"id"] uri:data[@"uri"]];
- } else if ([@"hls" isEqualToString:type]) {
- return [[UriAudioSource alloc] initWithId:data[@"id"] uri:data[@"uri"]];
- } else if ([@"concatenating" isEqualToString:type]) {
- return [[ConcatenatingAudioSource alloc] initWithId:data[@"id"]
- audioSources:[self decodeAudioSources:data[@"audioSources"]]];
- } else if ([@"clipping" isEqualToString:type]) {
- return [[ClippingAudioSource alloc] initWithId:data[@"id"]
- audioSource:[self decodeAudioSource:data[@"audioSource"]]
- start:data[@"start"]
- end:data[@"end"]];
- } else if ([@"looping" isEqualToString:type]) {
- NSMutableArray *childSources = [NSMutableArray new];
- int count = [data[@"count"] intValue];
- for (int i = 0; i < count; i++) {
- [childSources addObject:[self decodeAudioSource:data[@"audioSource"]]];
- }
- return [[LoopingAudioSource alloc] initWithId:data[@"id"] audioSources:childSources];
- } else {
- return nil;
- }
- }
- - (void)enqueueFrom:(int)index {
- _index = index;
- // Update the queue while keeping the currently playing item untouched.
- /* NSLog(@"before reorder: _player.items.count: ", _player.items.count); */
- /* [self dumpQueue]; */
- // First, remove all _player items except for the currently playing one (if any).
- IndexedPlayerItem *oldItem = _player.currentItem;
- IndexedPlayerItem *existingItem = nil;
- IndexedPlayerItem *newItem = _indexedAudioSources.count > 0 ? _indexedAudioSources[_index].playerItem : nil;
- NSArray *oldPlayerItems = [NSArray arrayWithArray:_player.items];
- // In the first pass, preserve the old and new items.
- for (int i = 0; i < oldPlayerItems.count; i++) {
- if (oldPlayerItems[i] == newItem) {
- // Preserve and tag new item if it is already in the queue.
- existingItem = oldPlayerItems[i];
- //NSLog(@"Preserving existing item %d", [self indexForItem:existingItem]);
- } else if (oldPlayerItems[i] == oldItem) {
- //NSLog(@"Preserving old item %d", [self indexForItem:oldItem]);
- // Temporarily preserve old item, just to avoid jumping to
- // intermediate queue positions unnecessarily. We only want to jump
- // once to _index.
- } else {
- //NSLog(@"Removing item %d", [self indexForItem:oldPlayerItems[i]]);
- [_player removeItem:oldPlayerItems[i]];
- }
- }
- // In the second pass, remove the old item (if different from new item).
- if (oldItem && newItem != oldItem) {
- //NSLog(@"removing old item %d", [self indexForItem:oldItem]);
- [_player removeItem:oldItem];
- }
- /* NSLog(@"inter order: _player.items.count: ", _player.items.count); */
- /* [self dumpQueue]; */
- // Regenerate queue
- BOOL include = NO;
- for (int i = 0; i < [_order count]; i++) {
- int si = [_order[i] intValue];
- if (si == _index) include = YES;
- if (include && _indexedAudioSources[si].playerItem != existingItem) {
- //NSLog(@"inserting item %d", si);
- [_player insertItem:_indexedAudioSources[si].playerItem afterItem:nil];
- }
- }
- /* NSLog(@"after reorder: _player.items.count: ", _player.items.count); */
- /* [self dumpQueue]; */
- if (_processingState != loading && oldItem != newItem) {
- // || !_player.currentItem.playbackLikelyToKeepUp;
- if (_player.currentItem.playbackBufferEmpty) {
- [self enterBuffering:@"enqueueFrom playbackBufferEmpty"];
- } else {
- [self leaveBuffering:@"enqueueFrom !playbackBufferEmpty"];
- }
- [self updatePosition];
- }
- [self updateEndAction];
- }
- - (void)updatePosition {
- _updatePosition = [self getCurrentPosition];
- _updateTime = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
- }
- - (void)load:(NSDictionary *)source result:(FlutterResult)result {
- if (!_playing) {
- [_player pause];
- }
- if (_processingState == loading) {
- [self abortExistingConnection];
- }
- _loadResult = result;
- _index = 0;
- [self updatePosition];
- _processingState = loading;
- [self broadcastPlaybackEvent];
- // Remove previous observers
- if (_indexedAudioSources) {
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- [self removeItemObservers:_indexedAudioSources[i].playerItem];
- }
- }
- // Decode audio source
- if (_audioSource && [@"clipping" isEqualToString:source[@"type"]]) {
- // Check if we're clipping an audio source that was previously loaded.
- UriAudioSource *child = nil;
- if ([_audioSource isKindOfClass:[ClippingAudioSource class]]) {
- ClippingAudioSource *clipper = (ClippingAudioSource *)_audioSource;
- child = clipper.audioSource;
- } else if ([_audioSource isKindOfClass:[UriAudioSource class]]) {
- child = (UriAudioSource *)_audioSource;
- }
- if (child) {
- _audioSource = [[ClippingAudioSource alloc] initWithId:source[@"id"]
- audioSource:child
- start:source[@"start"]
- end:source[@"end"]];
- } else {
- _audioSource = [self decodeAudioSource:source];
- }
- } else {
- _audioSource = [self decodeAudioSource:source];
- }
- _indexedAudioSources = [[NSMutableArray alloc] init];
- [_audioSource buildSequence:_indexedAudioSources treeIndex:0];
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- IndexedAudioSource *source = _indexedAudioSources[i];
- [self addItemObservers:source.playerItem];
- source.playerItem.audioSource = source;
- }
- [self updateOrder];
- // Set up an empty player
- if (!_player) {
- _player = [[AVQueuePlayer alloc] initWithItems:@[]];
- if (@available(macOS 10.12, iOS 10.0, *)) {
- _player.automaticallyWaitsToMinimizeStalling = _automaticallyWaitsToMinimizeStalling;
- // TODO: Remove these observers in dispose.
- [_player addObserver:self
- forKeyPath:@"timeControlStatus"
- options:NSKeyValueObservingOptionNew
- context:nil];
- }
- [_player addObserver:self
- forKeyPath:@"currentItem"
- options:NSKeyValueObservingOptionNew
- context:nil];
- // TODO: learn about the different ways to define weakSelf.
- //__weak __typeof__(self) weakSelf = self;
- //typeof(self) __weak weakSelf = self;
- __unsafe_unretained typeof(self) weakSelf = self;
- if (@available(macOS 10.12, iOS 10.0, *)) {}
- else {
- _timeObserver = [_player addPeriodicTimeObserverForInterval:CMTimeMake(200, 1000)
- queue:nil
- usingBlock:^(CMTime time) {
- [weakSelf checkForDiscontinuity];
- }
- ];
- }
- }
- // Initialise the AVQueuePlayer with items.
- [self enqueueFrom:0];
- // Notify each IndexedAudioSource that it's been attached to the player.
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- [_indexedAudioSources[i] attach:_player];
- }
- if (_player.currentItem.status == AVPlayerItemStatusReadyToPlay) {
- _loadResult(@([self getDuration]));
- _loadResult = nil;
- } else {
- // We send result after the playerItem is ready in observeValueForKeyPath.
- }
- [self broadcastPlaybackEvent];
- }
- - (void)updateOrder {
- if (_shuffleModeEnabled) {
- [_audioSource shuffle:0 currentIndex: _index];
- }
- _orderInv = [NSMutableArray arrayWithCapacity:[_indexedAudioSources count]];
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- [_orderInv addObject:@(0)];
- }
- if (_shuffleModeEnabled) {
- _order = [_audioSource getShuffleOrder];
- } else {
- NSMutableArray *order = [[NSMutableArray alloc] init];
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- [order addObject:@(i)];
- }
- _order = order;
- }
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- _orderInv[[_order[i] intValue]] = @(i);
- }
- }
- - (void)onItemStalled:(NSNotification *)notification {
- IndexedPlayerItem *playerItem = (IndexedPlayerItem *)notification.object;
- NSLog(@"onItemStalled");
- }
- - (void)onFailToComplete:(NSNotification *)notification {
- IndexedPlayerItem *playerItem = (IndexedPlayerItem *)notification.object;
- NSLog(@"onFailToComplete");
- }
- - (void)onComplete:(NSNotification *)notification {
- NSLog(@"onComplete");
- if (_loopMode == loopOne) {
- [self seek:kCMTimeZero index:@(_index) completionHandler:^(BOOL finished) {
- // XXX: Not necessary?
- [self play];
- }];
- } else {
- IndexedPlayerItem *endedPlayerItem = (IndexedPlayerItem *)notification.object;
- IndexedAudioSource *endedSource = endedPlayerItem.audioSource;
- if ([_orderInv[_index] intValue] + 1 < [_order count]) {
- // When an item ends, seek back to its beginning.
- [endedSource seek:kCMTimeZero];
- // account for automatic move to next item
- _index = [_order[[_orderInv[_index] intValue] + 1] intValue];
- NSLog(@"advance to next: index = %d", _index);
- [self updateEndAction];
- [self broadcastPlaybackEvent];
- } else {
- // reached end of playlist
- if (_loopMode == loopAll) {
- NSLog(@"Loop back to first item");
- // Loop back to the beginning
- // TODO: Currently there will be a gap at the loop point.
- // Maybe we can do something clever by temporarily adding the
- // first playlist item at the end of the queue, although this
- // will affect any code that assumes the queue always
- // corresponds to a contiguous region of the indexed audio
- // sources.
- // For now we just do a seek back to the start.
- if ([_order count] == 1) {
- [self seek:kCMTimeZero index:_order[0] completionHandler:^(BOOL finished) {
- // XXX: Necessary?
- [self play];
- }];
- } else {
- // When an item ends, seek back to its beginning.
- [endedSource seek:kCMTimeZero];
- [self seek:kCMTimeZero index:_order[0] completionHandler:^(BOOL finished) {
- // XXX: Necessary?
- [self play];
- }];
- }
- } else {
- [self complete];
- }
- }
- }
- }
- - (void)observeValueForKeyPath:(NSString *)keyPath
- ofObject:(id)object
- change:(NSDictionary<NSString *,id> *)change
- context:(void *)context {
- if ([keyPath isEqualToString:@"status"]) {
- IndexedPlayerItem *playerItem = (IndexedPlayerItem *)object;
- AVPlayerItemStatus status = AVPlayerItemStatusUnknown;
- NSNumber *statusNumber = change[NSKeyValueChangeNewKey];
- if ([statusNumber isKindOfClass:[NSNumber class]]) {
- status = statusNumber.intValue;
- }
- switch (status) {
- case AVPlayerItemStatusReadyToPlay: {
- if (playerItem != _player.currentItem) return;
- // Detect buffering in different ways depending on whether we're playing
- if (_playing) {
- if (@available(macOS 10.12, iOS 10.0, *)) {
- if (_player.timeControlStatus == AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate) {
- [self enterBuffering:@"ready to play: playing, waitingToPlay"];
- } else {
- [self leaveBuffering:@"ready to play: playing, !waitingToPlay"];
- }
- [self updatePosition];
- } else {
- // If this happens when we're playing, check whether buffer is confirmed
- if (_bufferUnconfirmed && !_player.currentItem.playbackBufferFull) {
- // Stay in bufering - XXX Test
- [self enterBuffering:@"ready to play: playing, bufferUnconfirmed && !playbackBufferFull"];
- } else {
- if (_player.currentItem.playbackBufferEmpty) {
- // !_player.currentItem.playbackLikelyToKeepUp;
- [self enterBuffering:@"ready to play: playing, playbackBufferEmpty"];
- } else {
- [self leaveBuffering:@"ready to play: playing, !playbackBufferEmpty"];
- }
- [self updatePosition];
- }
- }
- } else {
- if (_player.currentItem.playbackBufferEmpty) {
- [self enterBuffering:@"ready to play: !playing, playbackBufferEmpty"];
- // || !_player.currentItem.playbackLikelyToKeepUp;
- } else {
- [self leaveBuffering:@"ready to play: !playing, !playbackBufferEmpty"];
- }
- [self updatePosition];
- }
- [self broadcastPlaybackEvent];
- if (_loadResult) {
- _loadResult(@([self getDuration]));
- _loadResult = nil;
- }
- break;
- }
- case AVPlayerItemStatusFailed: {
- NSLog(@"AVPlayerItemStatusFailed");
- [self sendErrorForItem:playerItem];
- break;
- }
- case AVPlayerItemStatusUnknown:
- break;
- }
- } else if ([keyPath isEqualToString:@"playbackBufferEmpty"] || [keyPath isEqualToString:@"playbackBufferFull"]) {
- // Use these values to detect buffering.
- IndexedPlayerItem *playerItem = (IndexedPlayerItem *)object;
- if (playerItem != _player.currentItem) return;
- // If there's a seek in progress, these values are unreliable
- if (CMTIME_IS_VALID(_seekPos)) return;
- // Detect buffering in different ways depending on whether we're playing
- if (_playing) {
- if (@available(macOS 10.12, iOS 10.0, *)) {
- // We handle this with timeControlStatus instead.
- } else {
- if (_bufferUnconfirmed && playerItem.playbackBufferFull) {
- _bufferUnconfirmed = NO;
- [self leaveBuffering:@"playing, _bufferUnconfirmed && playbackBufferFull"];
- [self updatePosition];
- NSLog(@"Buffering confirmed! leaving buffering");
- [self broadcastPlaybackEvent];
- }
- }
- } else {
- if (playerItem.playbackBufferEmpty) {
- [self enterBuffering:@"!playing, playbackBufferEmpty"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- } else if (!playerItem.playbackBufferEmpty || playerItem.playbackBufferFull) {
- _processingState = ready;
- [self leaveBuffering:@"!playing, !playbackBufferEmpty || playbackBufferFull"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- }
- }
- /* } else if ([keyPath isEqualToString:@"playbackLikelyToKeepUp"]) { */
- } else if ([keyPath isEqualToString:@"timeControlStatus"]) {
- if (@available(macOS 10.12, iOS 10.0, *)) {
- AVPlayerTimeControlStatus status = AVPlayerTimeControlStatusPaused;
- NSNumber *statusNumber = change[NSKeyValueChangeNewKey];
- if ([statusNumber isKindOfClass:[NSNumber class]]) {
- status = statusNumber.intValue;
- }
- switch (status) {
- case AVPlayerTimeControlStatusPaused:
- //NSLog(@"AVPlayerTimeControlStatusPaused");
- break;
- case AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate:
- //NSLog(@"AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate");
- if (_processingState != completed) {
- [self enterBuffering:@"timeControlStatus"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- } else {
- NSLog(@"Ignoring wait signal because we reached the end");
- }
- break;
- case AVPlayerTimeControlStatusPlaying:
- [self leaveBuffering:@"timeControlStatus"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- break;
- }
- }
- } else if ([keyPath isEqualToString:@"currentItem"] && _player.currentItem) {
- if (_player.currentItem.status == AVPlayerItemStatusFailed) {
- if ([_orderInv[_index] intValue] + 1 < [_order count]) {
- // account for automatic move to next item
- _index = [_order[[_orderInv[_index] intValue] + 1] intValue];
- NSLog(@"advance to next on error: index = %d", _index);
- [self updateEndAction];
- [self broadcastPlaybackEvent];
- } else {
- NSLog(@"error on last item");
- }
- return;
- } else {
- int expectedIndex = [self indexForItem:_player.currentItem];
- if (_index != expectedIndex) {
- // AVQueuePlayer will sometimes skip over error items without
- // notifying this observer.
- NSLog(@"Queue change detected. Adjusting index from %d -> %d", _index, expectedIndex);
- _index = expectedIndex;
- [self updateEndAction];
- [self broadcastPlaybackEvent];
- }
- }
- //NSLog(@"currentItem changed. _index=%d", _index);
- _bufferUnconfirmed = YES;
- // If we've skipped or transitioned to a new item and we're not
- // currently in the middle of a seek
- if (CMTIME_IS_INVALID(_seekPos) && _player.currentItem.status == AVPlayerItemStatusReadyToPlay) {
- [self updatePosition];
- IndexedAudioSource *source = ((IndexedPlayerItem *)_player.currentItem).audioSource;
- // We should already be at position zero but for
- // ClippingAudioSource it might be off by some milliseconds so we
- // consider anything <= 100 as close enough.
- if ((int)(1000 * CMTimeGetSeconds(source.position)) > 100) {
- NSLog(@"On currentItem change, seeking back to zero");
- BOOL shouldResumePlayback = NO;
- AVPlayerActionAtItemEnd originalEndAction = _player.actionAtItemEnd;
- if (_playing && CMTimeGetSeconds(CMTimeSubtract(source.position, source.duration)) >= 0) {
- NSLog(@"Need to pause while rewinding because we're at the end");
- shouldResumePlayback = YES;
- _player.actionAtItemEnd = AVPlayerActionAtItemEndPause;
- [_player pause];
- }
- [self enterBuffering:@"currentItem changed, seeking"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- [source seek:kCMTimeZero completionHandler:^(BOOL finished) {
- [self leaveBuffering:@"currentItem changed, finished seek"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- if (shouldResumePlayback) {
- _player.actionAtItemEnd = originalEndAction;
- // TODO: This logic is almost duplicated in seek. See if we can reuse this code.
- _player.rate = _speed;
- }
- }];
- } else {
- // Already at zero, no need to seek.
- }
- }
- } else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
- IndexedPlayerItem *playerItem = (IndexedPlayerItem *)object;
- if (playerItem != _player.currentItem) return;
- int pos = [self getBufferedPosition];
- if (pos != _bufferedPosition) {
- _bufferedPosition = pos;
- [self broadcastPlaybackEvent];
- }
- }
- }
- - (void)sendErrorForItem:(IndexedPlayerItem *)playerItem {
- FlutterError *flutterError = [FlutterError errorWithCode:[NSString stringWithFormat:@"%d", playerItem.error.code]
- message:playerItem.error.localizedDescription
- details:nil];
- [self sendError:flutterError playerItem:playerItem];
- }
- - (void)sendError:(FlutterError *)flutterError playerItem:(IndexedPlayerItem *)playerItem {
- NSLog(@"sendError");
- if (_loadResult && playerItem == _player.currentItem) {
- _loadResult(flutterError);
- _loadResult = nil;
- }
- if (_eventSink) {
- // Broadcast all errors even if they aren't on the current item.
- _eventSink(flutterError);
- }
- }
- - (void)abortExistingConnection {
- FlutterError *flutterError = [FlutterError errorWithCode:@"abort"
- message:@"Connection aborted"
- details:nil];
- [self sendError:flutterError playerItem:nil];
- }
- - (int)indexForItem:(IndexedPlayerItem *)playerItem {
- for (int i = 0; i < _indexedAudioSources.count; i++) {
- if (_indexedAudioSources[i].playerItem == playerItem) {
- return i;
- }
- }
- return -1;
- }
- - (void)play {
- [self play:nil];
- }
- - (void)play:(FlutterResult)result {
- if (result) {
- if (_playResult) {
- NSLog(@"INTERRUPTING PLAY");
- _playResult(nil);
- }
- _playResult = result;
- }
- _playing = YES;
- #if TARGET_OS_IPHONE
- if (_configuredSession) {
- [[AVAudioSession sharedInstance] setActive:YES error:nil];
- }
- #endif
- _player.rate = _speed;
- [self updatePosition];
- if (@available(macOS 10.12, iOS 10.0, *)) {}
- else {
- if (_bufferUnconfirmed && !_player.currentItem.playbackBufferFull) {
- [self enterBuffering:@"play, _bufferUnconfirmed && !playbackBufferFull"];
- [self broadcastPlaybackEvent];
- }
- }
- }
- - (void)pause {
- _playing = NO;
- [_player pause];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- if (_playResult) {
- NSLog(@"PLAY FINISHED DUE TO PAUSE");
- _playResult(nil);
- _playResult = nil;
- }
- }
- - (void)complete {
- [self updatePosition];
- _processingState = completed;
- [self broadcastPlaybackEvent];
- if (_playResult) {
- NSLog(@"PLAY FINISHED DUE TO COMPLETE");
- _playResult(nil);
- _playResult = nil;
- }
- }
- - (void)setVolume:(float)volume {
- [_player setVolume:volume];
- }
- - (void)setSpeed:(float)speed {
- if (speed == 1.0
- || (speed < 1.0 && _player.currentItem.canPlaySlowForward)
- || (speed > 1.0 && _player.currentItem.canPlayFastForward)) {
- _speed = speed;
- if (_playing) {
- _player.rate = speed;
- }
- }
- [self updatePosition];
- }
- - (void)setLoopMode:(int)loopMode {
- _loopMode = loopMode;
- [self updateEndAction];
- }
- - (void)updateEndAction {
- // Should update this whenever the audio source changes and whenever _index changes.
- if (!_player) return;
- if (_audioSource && [_orderInv[_index] intValue] + 1 < [_order count] && _loopMode != loopOne) {
- _player.actionAtItemEnd = AVPlayerActionAtItemEndAdvance;
- } else {
- _player.actionAtItemEnd = AVPlayerActionAtItemEndPause; // AVPlayerActionAtItemEndNone
- }
- }
- - (void)setShuffleModeEnabled:(BOOL)shuffleModeEnabled {
- NSLog(@"setShuffleModeEnabled: %d", shuffleModeEnabled);
- _shuffleModeEnabled = shuffleModeEnabled;
- if (!_audioSource) return;
- [self updateOrder];
- [self enqueueFrom:_index];
- }
- - (void)dumpQueue {
- for (int i = 0; i < _player.items.count; i++) {
- IndexedPlayerItem *playerItem = _player.items[i];
- for (int j = 0; j < _indexedAudioSources.count; j++) {
- IndexedAudioSource *source = _indexedAudioSources[j];
- if (source.playerItem == playerItem) {
- NSLog(@"- %d", j);
- break;
- }
- }
- }
- }
- - (void)setAutomaticallyWaitsToMinimizeStalling:(bool)automaticallyWaitsToMinimizeStalling {
- _automaticallyWaitsToMinimizeStalling = automaticallyWaitsToMinimizeStalling;
- if (@available(macOS 10.12, iOS 10.0, *)) {
- if(_player) {
- _player.automaticallyWaitsToMinimizeStalling = automaticallyWaitsToMinimizeStalling;
- }
- }
- }
- - (void)seek:(CMTime)position index:(NSNumber *)newIndex completionHandler:(void (^)(BOOL))completionHandler {
- int index = _index;
- if (newIndex != [NSNull null]) {
- index = [newIndex intValue];
- }
- if (index != _index) {
- // Jump to a new item
- /* if (_playing && index == _index + 1) { */
- /* // Special case for jumping to the very next item */
- /* NSLog(@"seek to next item: %d -> %d", _index, index); */
- /* [_indexedAudioSources[_index] seek:kCMTimeZero]; */
- /* _index = index; */
- /* [_player advanceToNextItem]; */
- /* [self broadcastPlaybackEvent]; */
- /* } else */
- {
- // Jump to a distant item
- //NSLog(@"seek# jump to distant item: %d -> %d", _index, index);
- if (_playing) {
- [_player pause];
- }
- [_indexedAudioSources[_index] seek:kCMTimeZero];
- // The "currentItem" key observer will respect that a seek is already in progress
- _seekPos = position;
- [self updatePosition];
- [self enqueueFrom:index];
- IndexedAudioSource *source = _indexedAudioSources[_index];
- if (abs((int)(1000 * CMTimeGetSeconds(CMTimeSubtract(source.position, position)))) > 100) {
- [self enterBuffering:@"seek to index"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- [source seek:position completionHandler:^(BOOL finished) {
- if (@available(macOS 10.12, iOS 10.0, *)) {
- if (_playing) {
- // Handled by timeControlStatus
- } else {
- if (_bufferUnconfirmed && !_player.currentItem.playbackBufferFull) {
- // Stay in buffering
- } else if (source.playerItem.status == AVPlayerItemStatusReadyToPlay) {
- [self leaveBuffering:@"seek to index finished, (!bufferUnconfirmed || playbackBufferFull) && ready to play"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- }
- }
- } else {
- if (_bufferUnconfirmed && !_player.currentItem.playbackBufferFull) {
- // Stay in buffering
- } else if (source.playerItem.status == AVPlayerItemStatusReadyToPlay) {
- [self leaveBuffering:@"seek to index finished, (!bufferUnconfirmed || playbackBufferFull) && ready to play"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- }
- }
- if (_playing) {
- _player.rate = _speed;
- }
- _seekPos = kCMTimeInvalid;
- [self broadcastPlaybackEvent];
- if (completionHandler) {
- completionHandler(finished);
- }
- }];
- } else {
- _seekPos = kCMTimeInvalid;
- if (_playing) {
- if (@available(iOS 10.0, *)) {
- // NOTE: Re-enable this line only after figuring out
- // how to detect buffering when buffered audio is not
- // immediately available.
- //[_player playImmediatelyAtRate:_speed];
- _player.rate = _speed;
- } else {
- _player.rate = _speed;
- }
- }
- }
- }
- } else {
- // Seek within an item
- if (_playing) {
- [_player pause];
- }
- _seekPos = position;
- //NSLog(@"seek. enter buffering. pos = %d", (int)(1000*CMTimeGetSeconds(_indexedAudioSources[_index].position)));
- // TODO: Move this into a separate method so it can also
- // be used in skip.
- [self enterBuffering:@"seek"];
- [self updatePosition];
- [self broadcastPlaybackEvent];
- [_indexedAudioSources[_index] seek:position completionHandler:^(BOOL finished) {
- [self updatePosition];
- if (_playing) {
- // If playing, buffering will be detected either by:
- // 1. checkForDiscontinuity
- // 2. timeControlStatus
- if (@available(iOS 10.0, *)) {
- // NOTE: Re-enable this line only after figuring out how to
- // detect buffering when buffered audio is not immediately
- // available.
- //[_player playImmediatelyAtRate:_speed];
- _player.rate = _speed;
- } else {
- _player.rate = _speed;
- }
- } else {
- // If not playing, there is no reliable way to detect
- // when buffering has completed, so we use
- // !playbackBufferEmpty. Although this always seems to
- // be full even right after a seek.
- if (_player.currentItem.playbackBufferEmpty) {
- [self enterBuffering:@"seek finished, playbackBufferEmpty"];
- } else {
- [self leaveBuffering:@"seek finished, !playbackBufferEmpty"];
- }
- [self updatePosition];
- if (_processingState != buffering) {
- [self broadcastPlaybackEvent];
- }
- }
- _seekPos = kCMTimeInvalid;
- [self broadcastPlaybackEvent];
- if (completionHandler) {
- completionHandler(finished);
- }
- }];
- }
- }
- - (void)dispose {
- if (_processingState != none) {
- [_player pause];
- _processingState = none;
- [self broadcastPlaybackEvent];
- }
- if (_timeObserver) {
- [_player removeTimeObserver:_timeObserver];
- _timeObserver = 0;
- }
- if (_indexedAudioSources) {
- for (int i = 0; i < [_indexedAudioSources count]; i++) {
- [self removeItemObservers:_indexedAudioSources[i].playerItem];
- }
- }
- if (_player) {
- [_player removeObserver:self forKeyPath:@"currentItem"];
- if (@available(macOS 10.12, iOS 10.0, *)) {
- [_player removeObserver:self forKeyPath:@"timeControlStatus"];
- }
- _player = nil;
- }
- // Untested:
- // [_eventChannel setStreamHandler:nil];
- // [_methodChannel setMethodHandler:nil];
- }
- @end
|