Objective-C/AVFoundationQueuePlayer-iOS/AAPLPlayerViewController.m

/*
    Copyright (C) 2016 Apple Inc. All Rights Reserved.
    See LICENSE.txt for this sample’s licensing information
    
    Abstract:
    View controller containing a player view and basic playback controls.
*/
 
 
@import Foundation;
@import AVFoundation;
@import CoreMedia.CMTime;
#import "AAPLPlayerViewController.h"
#import "AAPLPlayerView.h"
#import "AAPLQueuedItemCollectionViewCell.h"
 
 
// Private properties
@interface AAPLPlayerViewController ()
{
    AVQueuePlayer *_player;
    AVURLAsset *_asset;
 
    /*
        A token obtained from calling `player`'s `addPeriodicTimeObserverForInterval(_:queue:usingBlock:)`
        method.
    */
    id<NSObject> _timeObserverToken;
    AVPlayerItem *_playerItem;
}
 
@property (readonly) AVPlayerLayer *playerLayer;
 
@property NSMutableDictionary *assetTitlesAndThumbnailsByURL;
 
// Formatter to provide formatted value for seconds displayed in `startTimeLabel` and `durationLabel`.
@property (readonly) NSDateComponentsFormatter *timeRemainingFormatter;
 
@property (weak) IBOutlet UISlider *timeSlider;
@property (weak) IBOutlet UILabel *startTimeLabel;
@property (weak) IBOutlet UILabel *durationLabel;
@property (weak) IBOutlet UIButton *rewindButton;
@property (weak) IBOutlet UIButton *playPauseButton;
@property (weak) IBOutlet UIButton *fastForwardButton;
@property (weak) IBOutlet UIButton *clearButton;
@property (weak) IBOutlet UICollectionView *collectionView;
@property (weak) IBOutlet UILabel *queueLabel;
@property (weak) IBOutlet AAPLPlayerView *playerView;
 
@end
 
@implementation AAPLPlayerViewController
 
// MARK: - View Controller
 
/*
    KVO context used to differentiate KVO callbacks for this class versus other
    classes in its class hierarchy.
*/
static int AAPLPlayerViewControllerKVOContext = 0;
 
- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
 
    /*
        Update the UI when these player properties change.
    
        Use the context parameter to distinguish KVO for our particular observers and not
        those destined for a subclass that also happens to be observing these properties.
    */
    [self addObserver:self forKeyPath:@"player.currentItem.duration" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context:&AAPLPlayerViewControllerKVOContext];
    [self addObserver:self forKeyPath:@"player.rate" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context:&AAPLPlayerViewControllerKVOContext];
    [self addObserver:self forKeyPath:@"player.currentItem.status" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context:&AAPLPlayerViewControllerKVOContext];
    [self addObserver:self forKeyPath:@"player.currentItem" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context:&AAPLPlayerViewControllerKVOContext];
 
    self.playerView.playerLayer.player = self.player;
 
    /*
        Read the list of assets we'll be using from a JSON file.
    */
    [self asynchronouslyLoadURLAssetsWithManifestURL:[[NSBundle mainBundle] URLForResource:@"MediaManifest" withExtension:@"json"]];
 
    // Use a weak self variable to avoid a retain cycle in the block.
    AAPLPlayerViewController __weak *weakSelf = self;
    _timeObserverToken = [self.player addPeriodicTimeObserverForInterval:CMTimeMake(1, 1) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
        double timeElapsed = CMTimeGetSeconds(time);
        
        weakSelf.timeSlider.value = timeElapsed;
        weakSelf.startTimeLabel.text = [weakSelf createTimeString: timeElapsed];
    }];
}
 
- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];
 
    if (_timeObserverToken) {
        [self.player removeTimeObserver:_timeObserverToken];
        _timeObserverToken = nil;
    }
 
    [self.player pause];
 
    [self removeObserver:self forKeyPath:@"player.currentItem.duration" context:&AAPLPlayerViewControllerKVOContext];
    [self removeObserver:self forKeyPath:@"player.rate" context:&AAPLPlayerViewControllerKVOContext];
    [self removeObserver:self forKeyPath:@"player.currentItem.status" context:&AAPLPlayerViewControllerKVOContext];
    [self removeObserver:self forKeyPath:@"player.currentItem" context:&AAPLPlayerViewControllerKVOContext];
}
 
 
// MARK: - Properties
 
// Will attempt load and test these asset keys before playing
+ (NSArray *)assetKeysRequiredToPlay {
    return @[@"playable", @"hasProtectedContent"];
}
 
- (AVQueuePlayer *)player {
    if (!_player) {
        _player = [[AVQueuePlayer alloc] init];
    }
    return _player;
}
 
- (CMTime)currentTime {
    return self.player.currentTime;
}
 
- (void)setCurrentTime:(CMTime)newCurrentTime {
    [self.player seekToTime:newCurrentTime toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
}
 
- (CMTime)duration {
    return self.player.currentItem ? self.player.currentItem.duration : kCMTimeZero;
}
 
- (float)rate {
    return self.player.rate;
}
 
- (void)setRate:(float)newRate {
    self.player.rate = newRate;
}
 
- (AVPlayerLayer *)playerLayer {
    return self.playerView.playerLayer;
}
 
- (NSDateComponentsFormatter *)timeRemainingFormatter {
    NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init];
    formatter.zeroFormattingBehavior = NSDateComponentsFormatterZeroFormattingBehaviorPad;
    formatter.allowedUnits = NSCalendarUnitMinute | NSCalendarUnitSecond;
    
    return formatter;
}
 
// MARK: - Asset Loading
 
/*
    Prepare an AVAsset for use on a background thread. When the minimum set 
    of properties we require (`assetKeysRequiredToPlay`) are loaded then add
    the asset to the `assetTitlesAndThumbnails` dictionary. We'll use that 
    dictionary to populate the "Add Item" button popover.
*/
- (void)asynchronouslyLoadURLAsset:(AVURLAsset *)asset title:(NSString *)title thumbnailResourceName:(NSString *)thumbnailResourceName {
 
    /*
        Using AVAsset now runs the risk of blocking the current thread (the
        main UI thread) whilst I/O happens to populate the properties. It's 
        prudent to defer our work until the properties we need have been loaded.
    */
    [asset loadValuesAsynchronouslyForKeys:AAPLPlayerViewController.assetKeysRequiredToPlay completionHandler:^{
 
        /*
            The asset invokes its completion handler on an arbitrary queue.
            To avoid multiple threads using our internal state at the same time
            we'll elect to use the main thread at all times, let's dispatch
            our handler to the main queue.
        */
        dispatch_async(dispatch_get_main_queue(), ^{
            
            /*
                This method is called when the `AVAsset` for our URL has 
                completed the loading of the values of the specified array 
                of keys.
            */
            
            /*
                Test whether the values of each of the keys we need have been
                successfully loaded.
            */
            for (NSString *key in self.class.assetKeysRequiredToPlay) {
                NSError *error = nil;
                if ([asset statusOfValueForKey:key error:&error] == AVKeyValueStatusFailed) {
                    NSString *stringFormat = NSLocalizedString(@"error.asset_%@_key_%@_failed.description", @"Can't use this AVAsset because one of it's keys failed to load");
 
                    NSString *message = [NSString localizedStringWithFormat:stringFormat, title, key];
 
                    [self handleErrorWithMessage:message error:error];
 
                    return;
                }
            }
 
            // We can't play this asset.
            if (!asset.playable || asset.hasProtectedContent) {
                NSString *stringFormat = NSLocalizedString(@"error.asset_%@_not_playable.description", @"Can't use this AVAsset because it isn't playable or has protected content");
 
                NSString *message = [NSString localizedStringWithFormat:stringFormat, title];
 
                [self handleErrorWithMessage:message error:nil];
 
                return;
            }
 
            /*
                We can play this asset. Create a new AVPlayerItem and make it
                our player's current item.
            */
            if (!self.loadedAssets)
                self.loadedAssets = [NSMutableDictionary dictionary];
            self.loadedAssets[title] = asset;
 
            NSString *path = [[NSBundle mainBundle] pathForResource:[thumbnailResourceName stringByDeletingPathExtension] ofType:[thumbnailResourceName pathExtension]];
            UIImage *thumbnail = [[UIImage alloc] initWithContentsOfFile:path];
            if (!self.assetTitlesAndThumbnailsByURL) {
                self.assetTitlesAndThumbnailsByURL = [NSMutableDictionary dictionary];
            }
            self.assetTitlesAndThumbnailsByURL[asset.URL] = @{ @"title" : title, @"thumbnail" : thumbnail };
        });
    }];
}
 
/*
    Read the asset URLs, titles and thumbnail resource names from a JSON manifest
    file - then load each asset.
*/
- (void)asynchronouslyLoadURLAssetsWithManifestURL:(NSURL *)jsonURL
{
    NSArray *assetsArray = nil;
 
    NSData *jsonData = [[NSData alloc] initWithContentsOfURL:jsonURL];
    if (jsonData) {
        assetsArray = (NSArray *)[NSJSONSerialization JSONObjectWithData:jsonData options:0 error:nil];
        if (!assetsArray) {
            [self handleErrorWithMessage:NSLocalizedString(@"error.json_parse_failed.description", @"Failed to parse the assets manifest JSON") error:nil];
        }
    }
    else {
        [self handleErrorWithMessage:NSLocalizedString(@"error.json_open_failed.description", @"Failed to open the assets manifest JSON") error:nil];
    }
    
    for (NSDictionary *assetDict in assetsArray) {
    
        NSURL *mediaURL = nil;
        NSString *optionalResourceName = assetDict[@"mediaResourceName"];
        NSString *optionalURLString = assetDict[@"mediaURL"];
        if (optionalResourceName) {
            mediaURL = [[NSBundle mainBundle] URLForResource:[optionalResourceName stringByDeletingPathExtension] withExtension:optionalResourceName.pathExtension];
        }
        else if (optionalURLString) {
            mediaURL = [NSURL URLWithString:optionalURLString];
        }
 
        [self asynchronouslyLoadURLAsset:[AVURLAsset URLAssetWithURL:mediaURL options:nil]
                                   title:assetDict[@"title"]
                   thumbnailResourceName:assetDict[@"thumbnailResourceName"]];
    }
}
 
// MARK: - IBActions
 
- (IBAction)playPauseButtonWasPressed:(UIButton *)sender {
    if (self.player.rate != 1.0) {
        // Not playing foward; so play.
        if (CMTIME_COMPARE_INLINE(self.currentTime, ==, self.duration)) {
            // At end; so got back to beginning.
            self.currentTime = kCMTimeZero;
        }
        [self.player play];
    } else {
        // Playing; so pause.
        [self.player pause];
    }
}
 
- (IBAction)rewindButtonWasPressed:(UIButton *)sender {
    self.rate = MAX(self.player.rate - 2.0, -2.0); // rewind no faster than -2.0
}
 
- (IBAction)fastForwardButtonWasPressed:(UIButton *)sender {
    self.rate = MIN(self.player.rate + 2.0, 2.0); // fast forward no faster than 2.0
}
 
- (IBAction)timeSliderDidChange:(UISlider *)sender {
    self.currentTime = CMTimeMakeWithSeconds(sender.value, 1000);
}
 
- (void)presentModalPopoverAlertController:(UIAlertController *)alertController sender:(UIButton *)sender {
    alertController.modalPresentationStyle = UIModalPresentationPopover;
 
    alertController.popoverPresentationController.sourceView = sender;
    alertController.popoverPresentationController.sourceRect = sender.bounds;
    alertController.popoverPresentationController.permittedArrowDirections = UIPopoverArrowDirectionAny;
 
    [self presentViewController:alertController animated:true completion:nil];
}
 
- (IBAction)addItemToQueueButtonPressed:(UIButton *)sender {
 
    NSString *alertTitle = NSLocalizedString(@"popover.title.addItem", @"Title of popover that adds items to the queue");
    NSString *alertMessage = NSLocalizedString(@"popover.message.addItem", @"Message on popover that adds items to the queue");
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:alertTitle message:alertMessage preferredStyle:UIAlertControllerStyleActionSheet];
    
    // Populate the sheet with the titles of the assets we have loaded.
    for (NSString *loadedAssetTitle in self.loadedAssets.allKeys) {
        AVAsset *loadedAsset = self.loadedAssets[loadedAssetTitle];
        AAPLPlayerViewController __weak *weakSelf = self;
        [alertController addAction:[UIAlertAction actionWithTitle:loadedAssetTitle style:UIAlertActionStyleDefault handler:
            ^(UIAlertAction *action){
                NSArray *oldItemsArray = [weakSelf.player items];
                AVPlayerItem *newPlayerItem = [AVPlayerItem playerItemWithAsset:loadedAsset];
                [weakSelf.player insertItem:newPlayerItem afterItem:nil];
                [weakSelf queueDidChangeFromArray:oldItemsArray toArray:[self.player items]];
            }]];
    }
 
    NSString *cancelActionTitle = NSLocalizedString(@"popover.title.cancel", @"Title of popover cancel action");
    [alertController addAction:[UIAlertAction actionWithTitle:cancelActionTitle style:UIAlertActionStyleCancel handler:nil]];
 
    [self presentModalPopoverAlertController:alertController sender:sender];
}
 
- (IBAction)clearQueueButtonWasPressed:(UIButton *)sender {
 
    NSString *alertTitle = NSLocalizedString(@"popover.title.clear", @"Title of popover that clears the queue");
    NSString *alertMessage = NSLocalizedString(@"popover.message.clear", @"Message on popover that clears the queue");
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:alertTitle message:alertMessage preferredStyle:UIAlertControllerStyleActionSheet];
 
    AAPLPlayerViewController __weak *weakSelf = self;
    [alertController addAction:[UIAlertAction actionWithTitle:@"Clear Queue" style:UIAlertActionStyleDestructive handler:
        ^(UIAlertAction *action){
            NSArray *oldItemsArray = [weakSelf.player items];
            [weakSelf.player removeAllItems];
            [weakSelf queueDidChangeFromArray:oldItemsArray toArray:[self.player items]];
        }]];
 
    NSString *cancelActionTitle = NSLocalizedString(@"popover.title.cancel", @"Title of popover cancel action");
    [alertController addAction:[UIAlertAction actionWithTitle:cancelActionTitle style:UIAlertActionStyleCancel handler:nil]];
 
    [self presentModalPopoverAlertController:alertController sender:sender];
}
 
// MARK: - KV Observation
 
// Update our UI when player or player.currentItem changes
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
 
    if (context != &AAPLPlayerViewControllerKVOContext) {
        // KVO isn't for us.
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
        return;
    }
 
    if ([keyPath isEqualToString:@"player.currentItem"]) {
        [self queueDidChangeFromArray:nil toArray:[self.player items]];
 
    }
    else if ([keyPath isEqualToString:@"player.currentItem.duration"]) {
        // Update timeSlider and enable/disable controls when duration > 0.0
 
        // Handle NSNull value for NSKeyValueChangeNewKey, i.e. when player.currentItem is nil
        NSValue *newDurationAsValue = change[NSKeyValueChangeNewKey];
        CMTime newDuration = [newDurationAsValue isKindOfClass:[NSValue class]] ? newDurationAsValue.CMTimeValue : kCMTimeZero;
        BOOL hasValidDuration = CMTIME_IS_NUMERIC(newDuration) && newDuration.value != 0;
        double currentTime = hasValidDuration ? CMTimeGetSeconds(self.currentTime) : 0.0;
        double newDurationSeconds = hasValidDuration ? CMTimeGetSeconds(newDuration) : 0.0;
 
        self.timeSlider.maximumValue = newDurationSeconds;
        self.timeSlider.value = currentTime;
        self.rewindButton.enabled = hasValidDuration;
        self.playPauseButton.enabled = hasValidDuration;
        self.fastForwardButton.enabled = hasValidDuration;
        self.timeSlider.enabled = hasValidDuration;
        self.startTimeLabel.enabled = hasValidDuration;
        self.startTimeLabel.text = [self createTimeString:currentTime];
        self.durationLabel.enabled = hasValidDuration;
        self.durationLabel.text = [self createTimeString:newDurationSeconds];
    }
    else if ([keyPath isEqualToString:@"player.rate"]) {
        // Update playPauseButton image
 
        double newRate = [change[NSKeyValueChangeNewKey] doubleValue];
        UIImage *buttonImage = (newRate == 1.0) ? [UIImage imageNamed:@"PauseButton"] : [UIImage imageNamed:@"PlayButton"];
        [self.playPauseButton setImage:buttonImage forState:UIControlStateNormal];
 
    }
    else if ([keyPath isEqualToString:@"player.currentItem.status"]) {
        // Display an error if status becomes Failed
 
        // Handle NSNull value for NSKeyValueChangeNewKey, i.e. when player.currentItem is nil
        NSNumber *newStatusAsNumber = change[NSKeyValueChangeNewKey];
        AVPlayerItemStatus newStatus = [newStatusAsNumber isKindOfClass:[NSNumber class]] ? newStatusAsNumber.integerValue : AVPlayerItemStatusUnknown;
        
        if (newStatus == AVPlayerItemStatusFailed) {
            [self handleErrorWithMessage:self.player.currentItem.error.localizedDescription error:self.player.currentItem.error];
        }
 
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}
 
// Trigger KVO for anyone observing our properties affected by player and player.currentItem
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    if ([key isEqualToString:@"duration"]) {
        return [NSSet setWithArray:@[ @"player.currentItem.duration" ]];
    } else if ([key isEqualToString:@"currentTime"]) {
        return [NSSet setWithArray:@[ @"player.currentItem.currentTime" ]];
    } else if ([key isEqualToString:@"rate"]) {
        return [NSSet setWithArray:@[ @"player.rate" ]];
    } else {
        return [super keyPathsForValuesAffectingValueForKey:key];
    }
}
 
// player.items is not KV observable so we need to call this function every time the queue changes
- (void)queueDidChangeFromArray:(NSArray *)oldPlayerItems toArray:(NSArray *)newPlayerItems {
 
    if (newPlayerItems.count == 0) {
        self.queueLabel.text = NSLocalizedString(@"label.queue.empty", @"Queue is empty");
    }
    else {
        NSString *stringFormat = NSLocalizedString(@"label.queue.%lu items", @"Queue of n item(s)");
 
        self.queueLabel.text = [NSString localizedStringWithFormat:stringFormat, newPlayerItems.count];
    }
    
    BOOL isQueueEmpty = newPlayerItems.count == 0;
    self.clearButton.enabled = !isQueueEmpty;
 
    [self.collectionView reloadData];
}
 
// MARK: - Error Handling
 
- (void)handleErrorWithMessage:(NSString *)message error:(NSError *)error {
    NSLog(@"Error occurred with message: %@, error: %@.", message, error);
 
    NSString *alertTitle = NSLocalizedString(@"alert.error.title", @"Alert title for errors");
    NSString *defaultAlertMessage = NSLocalizedString(@"error.default.description", @"Default error message when no NSError provided");
    UIAlertController *controller = [UIAlertController alertControllerWithTitle:alertTitle message:message ?: defaultAlertMessage  preferredStyle:UIAlertControllerStyleAlert];
 
    NSString *alertActionTitle = NSLocalizedString(@"alert.error.actions.OK", @"OK on error alert");
    UIAlertAction *action = [UIAlertAction actionWithTitle:alertActionTitle style:UIAlertActionStyleDefault handler:nil];
    [controller addAction:action];
 
    [self presentViewController:controller animated:YES completion:nil];
}
 
// MARK: UICollectionViewDataSource
 
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return [self.player items].count;
}
 
- (NSDictionary *)titleAndThumbnailForPlayerItemAtIndexPath:(NSIndexPath *)indexPath {
    AVPlayerItem *item = [self.player items][[indexPath indexAtPosition:1]];
    return self.assetTitlesAndThumbnailsByURL[[(AVURLAsset *)item.asset URL]];
}
 
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
 
    AAPLQueuedItemCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"ItemCell" forIndexPath:indexPath];
 
    NSDictionary *titleAndThumbnail = [self titleAndThumbnailForPlayerItemAtIndexPath:indexPath];
    cell.label.text = titleAndThumbnail[@"title"];
    cell.backgroundView = [[UIImageView alloc] initWithImage:titleAndThumbnail[@"thumbnail"]];
 
    return cell;
}
 
// MARK: Convenience
 
- (NSString *)createTimeString:(double)time {
    NSDateComponents *components = [[NSDateComponents alloc] init];
    components.second = (NSInteger)fmax(0.0, time);
    
    return [self.timeRemainingFormatter stringFromDateComponents:components];
}
 
@end