AVFoundation 播放媒体资源

1 AVFoundation简介

1.1 Apple的媒体处理体系

Apple的媒体处理体系分为高、中、低三层,iOS中高级框架为【AVKit,UIKit】,Mac OS X中高级框架为【AVKit,APPKit】,中级和低级框架可以跨两个平台使用,中级框架为【AVFoundation】,低级框架为【Core Audio,Core Video,Core Media,Core Animation】。

Core Audio:由多个子框架组成,为音频和MIDI内容的录制、播放和处理提供相应接口。其中有高层级接口如Audio Queue Services负责处理音频播放和录音,低层接口如Audio Units可以对音频信号进行完全控制。

Core Video:为其对应的Core Media提供图片和缓存池支持,提供能够对视频逐帧访问接口。

Core Media:提供对音频样本和视频帧处理所需的低层级数据类型和接口。如提供的CMTime数据类型。

Core Animation:提供合成和动画相关功能,并且已经封装了支持OpenGL和OpenGL ES功能的各个类。使用Core Animation播放视频和捕获视频时,AVFoundation都提供硬件加速机制优化。Core Animation还用于合成动画标题和图片效果。

1.2 AVFoundation功能分类

1-音频播放和记录
通过AVAudioPlayer和AVAudioRecorder和相关类实现音频的播放和记录,它们不是音频记录的唯一方式,但是更简单和功能强大。
2-媒体文件检查
这类功能负责1)检查媒体资源是否支持回放,是否支持编辑和导出等信息。2)检查媒体资源持续时间、创建日期和首选播放音量等相关技术参数。3) 以AVMetadataItem为核心的类负责读写艺术家等媒体资源描述信息。
3-视频播放
通过AVPlayer和AVPLayerItem及相关类实现本地和远程视频的播放。
4-媒体捕捉
通过以AVCaptureSession为核心的类控制摄像头和麦克风捕捉媒体资源。
5-媒体编辑
AVFoundation可以合并多个音频和视频资源,允许修改和编辑独立的媒体片段,修改音频文件参数以及添加动画标题和场景切换效果。
6-媒体处理
通过AVAssetReader和AVAssetWriter可以直接读写视频帧和音频样本实现高级编辑功能。

1.3 数字媒体基础

生活中的声音是连续的震动波,图像是由不同反射率物质构成的场景,数字媒体中的声音是离散的电信号,视频是由一帧帧连续的图像组成,而每张图像是由用RGB等色彩表示的像素点组成。将生活中的媒体转换为数字媒体过程称为数字媒体采样。

数字媒体采样
数字媒体采样分为1)时间采样:如捕捉声音时将持续时间内的音调和音高变化记录下来。2)空间采样:如采集视频时捕捉一个场景的亮度和色度,创建由像素点构成的数据。

音频采样
音频采样关注声音的振幅和频率,前者决定音量,后者决定音色。麦克风能生成电流信号,对该信号采样用的编码方式为线性脉冲编码调制(LPCM)。采样的频率和单个样本的容量共同决定最后采样得到的数据大小。前者使用尼奎斯特频率(样本中最高频率的两倍),后者成为位元深度,CD音质位元深度为16即2个字节。

1.4 数字媒体压缩

1.4.1 色彩二次抽样

将RGB格式的颜色信息转换为YCbCr格式后,由于人眼对于亮度Y的敏感度高于蓝色颜色分量Cb和红色颜色分量Cr,因此对于单帧图片,可以使用存储全部的亮度信息,存储部分的颜色信息方案来达到压缩目的。这个过程称为色彩的二次抽样,其常用的格式分为4:4:4、4:2:2以及4:2:0,对于8*2个像素的图片,其具体的采样方案如下。专业相机常用4:4:4,大部分相机使用4:2:2,iPhone手机使用4:2:0方案。


色彩二次抽样常用格式
1.4.2 编解码器压缩

编码器可以通过高级压缩算法对数据进行压缩,其压缩方式分为有损压缩和无损压缩,zip和gzip属于无损压缩。但是对于媒体资源的压缩有着不同的算法,如声音数据可以减少人敏感声音范围【1kHz~5kHz】之外的数据达到压缩目的。

1.4.3 AVFoundation支持的视频编解码器

AVFoundation中支持H.264、Apple ProRes、MEPG-1、MPEG-2、MPEG-4、H.263、DV等编解码。

1.4.3.1 H.264
H.264是MPEG-4标准的一部分,它分别从空间和时间两个维度压缩视频资源。空间上:压缩独立的帧,称为帧内压缩。时间上:通过以组为单位的视频帧压缩冗余数据,称为帧间压缩。

在压缩过程中,编解码器会将已有的视频数据分组,每组中抽取1张画面压缩为关键帧,其余画面压缩为其他类型帧数据。如舞台表演节目,背景通常固定,固定的背景在时间维度上的冗余就可以通过帧间压缩消除。每组帧(GOP)可以有以下几个类型。

  • I-frames:称为关键帧,包含创建一张图片所有数据,尺寸最大,解压最快。
  • P-frames:称为预测帧,基于最近的I-frames和P-frames可以预测出的图片编码得到。
  • B-frames:称为双向帧,基于其前后的两帧编码所得,其几乎不需要存储空间,但解压费时。

H.264分了三个标准的压缩方式:

  • Baseline:这个标注不支持B-frames,压缩率最低,iPhone 3GS使用。
  • Main:中等压缩强度。
  • Hight:最复杂的压缩算法,有最高的压缩率。

1.4.3.2 Apple ProRes
Apple ProRes仅仅支持OS X环境,不支持iOS环境。其只能用I-frames,此外它支持可变比特率编码方式。

1.3.4 AVFoundation支持的音频编解码器

AVFoundation支持大量的音频解码器,如前文提到的基于LPCM的编解码器,但其生成的音频文件较大,通常使用的是基于H.264标准下的AAC标准的编解码器。另外AVFoundation只支持对MP3格式的数据的解码,而不支持编码。

1.4.4 容器格式

对于媒体文件,其.mp4等后缀名都是一种容器格式,每种不同的容器格式有着不同的规范确定文件的结构。上文提到的编解码器主要负责数据的压缩,而容器格式主要负责文件的结构。因此对于媒体文件可以有多种容器格式和编解码器的组合方式。但是对于单纯的音频文件,一种编解码器通常对应一种确定的容器格式。AVFoundation中常用的容器格式主要是以下两种:

  • QuickTime:苹果建立的一种容器格式,官方后缀名为.mov。
  • MPEG-4:从QuickTime衍生的行业标准格式,官方后缀名.mp4。苹果环境中存在代表音频文件的.m4a和代表视频文件的.m4v变体。

2 文本语音播报

AVFoundation中的鸡肋功能,只适合自娱自乐,可以通过不同口音播放文本。希望以后能从从机器语言进化为人类语言吧。

- (instancetype)init {
    if (self = [super init]) {
        _synthesizer = [[AVSpeechSynthesizer alloc] init];
        // 指定支持语言类型,调用AVSpeechSynthesisVoice的speechVoices获取支持语言清单
        _voices = @[[AVSpeechSynthesisVoice voiceWithLanguage:@"en-US"],
                    [AVSpeechSynthesisVoice voiceWithLanguage:@"en-GB"]];
        _speechStrings = [self buildSpeechStrings];
    }
    return self;
}

- (NSArray *)buildSpeechStrings {
    return @[@"Hello AV Foundation. ",
             @"Hello AV Foundation. "];
}

- (void)beginConversation {
    for (NSUInteger i = 0; i < self.speechStrings.count; i++) {
        AVSpeechUtterance *utterance = [[AVSpeechUtterance alloc] initWithString:self.speechStrings[i]];
        utterance.voice = self.voices[i%2];
        utterance.rate = 0.4f;
        // 播放的音调
        utterance.pitchMultiplier = 0.8f;
        // 每条语言播报之间的间隔
        utterance.postUtteranceDelay = 0.1f;
        [self.synthesizer speakUtterance:utterance];
    }
}

3 播放和录制音频

音频会话
应用程序在播放音乐时来电打断后应该采取的行为是混音还是暂停等都由音频会话AVAudioSession控制。音频会话根据行为不同有很多分类。每个程序都有一个默认生效的音频会话实例,可以通过【AVAudioSession sharedInstance】获得,其默认的分类为Solo Ambient,通常在Appdelegate中的didFinishLaunchingWithOptions方法内进行配置。

3.1 播放音频

3.1.1 AVAudioPlayer

AVAudioPlayer构建于Core Audio中的C-based Audio Queue Services最顶层。可以通过NSData和NSURL两种方式创建。在iOS中,URL必须是沙盒内的文件或者iPod中的一个元素。通过调用prepareToPlay将资源预加载到内存中,调用play播放(如果没有手动调用prepareToPlay,此时会隐性调用,会产生延迟)。

播放控制
可以通过play,pause,stop进行播放控制。pause和stop的区别在于前者不会撤销prepareToPlay的设置,后者会。此外还可以:
修改音量:播放器音量独立于系统音量之外,可以实现渐隐等效果。
修改pan:实现立体声,-1表示左耳,1表示右耳。
调整速率:可以加速和减速播放
循环播放:通过numberOfLoops循环播放,MP3格式不建议使用该功能。
音频计量:读取音量大小,绘制可视化界面。

3.1.2 创建播放器

实例化AVAudioPlayer对象

- (AVAudioPlayer *)playerForFile:(NSString *)name {
    NSURL *fileURL = [[NSBundle mainBundle] URLForResource:name withExtension:@"caf"];
    NSError *error;
    AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:&error];
    if (player) {
        player.numberOfLoops = -1;
        player.enableRate = YES;
        player.rate = 0.8;
        player.pan = -1;
        player.volume = 1;
        [player prepareToPlay];
    } else {
        NSLog(@"Error creating player: %@",[error localizedDescription]);
    }
    return player;
}

播放音频

- (void)play {
    //通过指定一个时间可以使个多个播放器同步播放
    NSTimeInterval delayTime = [player deviceCurrentTime] + 0.01;
    [player playAtTime:delayTime];
}
3.1.3 配置音频会话

上文提到当程序进入后台,或者设备锁屏时,播放因为行为有音频会话控制,因此需要根据需要定义音频会话类型。

- (void)configureAudioSession {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error;
    //更多会话类型见官网
    if (![audioSession setCategory:AVAudioSessionCategoryPlayback error:&error]) {
        NSLog(@"Category set failed: %@", error.localizedDescription);
    }
    if (![audioSession setActive:YES error:&error]) {
        NSLog(@"Audio session set active failed: %@",error.localizedDescription);
    }
}

除了配置音频会话外,还需要在info.plist中添加一个Required background modes的数组,增加一个App plays audio or streams audio/video using AirPlay选项才能生效。

3.1.4 处理中断事件
- (instancetype)init {
    if (self = [super init]) {
        [self registerAudioSessionNotification];
    }
    return self;
}

- (void)registerAudioSessionNotification {
    NSNotificationCenter *nsnc = [NSNotificationCenter defaultCenter];
    [nsnc addObserver:self
             selector:@selector(handleInterruption:)
                 name:AVAudioSessionInterruptionNotification
               object:[AVAudioSession sharedInstance]];
}

- (void)handleInterruption:(NSNotification *)notification {
    NSDictionary *info = notification.userInfo;
    AVAudioSessionInterruptionType type = [info[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue];
    if (type == AVAudioSessionInterruptionTypeBegan) {
        //处理中断开始
    } else {
        AVAudioSessionInterruptionOptions options = [info[AVAudioSessionInterruptionOptionKey] unsignedIntegerValue];
        if (options == AVAudioSessionInterruptionOptionShouldResume) {
            //处理中断结束
        }
    }
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}
3.1.5 处理线路改变

当插入耳机或者断开耳机时,AVAudioSession会发出线路改变通知,通过它可以控制程序的行为。

- (instancetype)init {
    if (self = [super init]) {
        [self registerRouteChangedNotification];
    }
    return self;
}

- (void)registerRouteChangedNotification {
    NSNotificationCenter *nsnc = [NSNotificationCenter defaultCenter];
    [nsnc addObserver:self
             selector:@selector(handleRouteChange:)
                 name:AVAudioSessionRouteChangeNotification
               object:[AVAudioSession sharedInstance]];
}

- (void)handleRouteChange:(NSNotification *)notification {
    NSDictionary *info = notification.userInfo;
    AVAudioSessionRouteChangeReason reason = [info[AVAudioSessionRouteChangeReasonKey] unsignedIntegerValue];
    //这个原因是旧设备不可用,其中包含耳机断开,更多原因见官方文档
    if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
        AVAudioSessionRouteDescription *previousRoute = info[AVAudioSessionRouteChangePreviousRouteKey];
        AVAudioSessionPortDescription *previousOutput = previousRoute.outputs.firstObject;
        NSString *portType = previousOutput.portType;
        if ([portType isEqualToString:AVAudioSessionPortHeadphones]) {
            //处理事件
        }
    }
}

3.2 录制音频

3.2.1 AVAudioRecorder

AVAudioRecorder同样建构于Audio Queue Services之上,能够使用内置和外部设备记录音频。实例化需要写入到本地文件的URL,包含配置信息的NSDictionary字典,捕捉错误信息的NSError指针。同样的在成功创建AVAudioRecorder实例后,可以通过调用prepareToRecord方法执行底层Audio Queue Services初始化必要过程,当使用一个录制器录制多个文件时,每次录制完成后都建议调用该方法。

配置信息的NSDictionary字典
1 音频格式:AVFormatIDKey指定写入内容的音频格式,常用MPEG4AAC和AppleIMA4,这里的格式必须和存储路径后缀名即容器相符,Core Audio Format(.caf)是常用的容器,其兼容Core Audio支持的任何音频格式。
2 采样率:AVSampleRateKey定义录音器采样率,采样率越高文件越大,质量越好,常用的采样率为8000(8HZ)、16000(16HZ)、22050(22.05HZ)、44100(44.1HZ)。
3 通道数:AVNumberOfChannelKey用于定义音频内容通道数,1代表单声道,2代表立体声,除外部设备外都应使用1。
4 指定格式的键:处理Liiner PCM或压缩音频格式时,可以自定义指定格式的键,在AV Foundation Audio Settings Constants中可以查看。

3.2.2 创建录音器

实例化AVAudioRecorder对象

- (void)createAudioRecorder {
    NSString *tmpDir = NSTemporaryDirectory();
    // Core Audio Format(CAF)容器可以保存Core Audio支持的任何音频格式
    NSString *filePath = [tmpDir stringByAppendingPathComponent:@"memo.caf"];
    NSURL *fileURL = [NSURL fileURLWithPath:filePath];
    
    NSDictionary *settings = @{
                               AVFormatIDKey : @(kAudioFormatAppleIMA4),
                               AVSampleRateKey : @44100.0f,
                               AVNumberOfChannelsKey : @1,
                               AVEncoderBitDepthHintKey : @16,
                               AVEncoderAudioQualityKey : @(AVAudioQualityMedium)
                               };
    NSError *error;
    self.recorder = [[AVAudioRecorder alloc] initWithURL:fileURL
                                                settings:settings
                                                   error:&error];
    if (self.recorder) {
        // 指定代理监听录音器录制完成行为。
        self.recorder.delegate = self;
        [self.recorder prepareToRecord];
    } else {
        NSLog(@"Error: %@",error.localizedDescription);
    }
}

开始和结束录制

//调用record和pause开始和暂停录制,结束录制需要单独处理,需实现Recorder的代理方法。
- (void)stop {
    [self.recorder stop];
}

- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)success {
    // 处理结束,通常将录制文件从temp文件夹保存到document文件夹
}
3.2.3 配置音频会话

要使用录音功能必须配置音频会话,因为默认的音频会话类型不支持录音功能。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self configureAudioSession];
    return YES;
}

- (void)configureAudioSession {
    AVAudioSession *session = [AVAudioSession sharedInstance];
    NSError *error;
    if (![session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error]) {
        NSLog(@"Category set failed, Error: %@",error.localizedDescription);
    }
    if (![session setActive:YES error:&error]) {
        NSLog(@"Session activation is failed, Error: %@",error.localizedDescription);
    }
}
3.2.4 获取麦克风授权

要使用麦克风设备必须获取用户授权,通过在info.plist文件中增加Privacy - Microphone Usage Description字段获取授权。在程序中检查用户授权状态,执行必要操作,也可以提示用户跳转至系统页进行设置,具体方法见iOS系统中各种授权设置

- (void)checkMicrophoneAuthorizationStatus {
    AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
    switch (authStatus) {
        case AVAuthorizationStatusDenied:
            break;
        case AVAuthorizationStatusAuthorized:
            break;
        case AVAuthorizationStatusRestricted:
            break;
        case AVAuthorizationStatusNotDetermined:
            break;
    }
}
3.2.5 保存音频文件
- (void)saveRecordingWithName:(NSString *)name completionHandler:(THRecordingSaveCompletionHandler)handler {
    NSTimeInterval timeStamp = [NSDate timeIntervalSinceReferenceDate];
    NSString *filename = [NSString stringWithFormat:@"%@-%f.caf", name, timeStamp];
    
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *docsDir = paths.firstObject;
    NSString *destPath = [docsDir stringByAppendingPathComponent:filename];
    NSURL *srcURL = self.recorder.url;
    NSURL *destURL = [NSURL fileURLWithPath:destPath];
    
    NSError *error;
    BOOL success = [[NSFileManager defaultManager] copyItemAtURL:srcURL
                                                           toURL:destURL
                                                           error:&error];
    if (success) {
        handler(YES, [THMemo memoWithTitle:name url:destURL]);
        //每次结束录制后需要重新准备下一次录制
        [self.recorder prepareToRecord];
    } else {
        handler(NO, error);
    }
}
3.2.6 监听录制时间

由于Recorder的currentTime属性不能通过KVO的方式监听,因此需要使用自定义定时器的方法轮询。

3.2 音频计量

AVAudioPlayer和AVAudioRecorder都可以启用meteringEnabled属性开启音频计量功能。它们都可以获取各个声道的平均和最高音量,其范围介于表示静音的-160dB(分贝) ~ 表示最大声音的0dB。这个值和现实生活中的分贝值不相同,需要经过转换,但如果用于画图只需将其投影至0 ~ 1的范围即可。

这里需要注意的是,可视化音频计量在小音频文件时可以使用Quartz方式绘制图形,它需要使用CPU资源。但是当处理长时间音频内容时,建议使用OpenGL ES绘制,其使用GPU资源。

float avgPower = [self.recorder averagePowerForChannel:0];
float peakPower = [self.recorder peakPowerForChannel:0];

4 资源和元数据

4.1 资源

4.1.1 资源含义

AVFoundation框架围绕资源(AVAsset)展开,AVAsset是一个抽象不可变类,它将媒体资源的标题、时长、元数据和媒体数据等静态属性整合为一个整体。AVAsset可以使我们不用考虑媒体资源的编码格式和容器格式,同样也使我们不用考虑文件的具体位置。AVAsset本身不是媒体资源,它只是容器,由多个带有自身元数据的媒体(AVAssetTrack)组成。AVAssetTrack常见的是音频和视频轨道,但是同样也可以是文本、字幕等轨道。

4.1.2 创建资源

AVAsset可以通过一个本地或者远程的URL创建,注意这里AVAsset是一个抽象类,实际创建的是AVURLAsset的子类。当需要精密调整资源创建方式时(如通过AVURLAssetPrefer PreciseDurationAndTimingKey可以获取更准确的时长信息),可以传递一个选项字典直接创建AVURLAsset实例。

在iOS设备中可以访问照片库中的视频资源,iPod库中的歌曲资源。在Mac中,可以获取iTunes库中的媒体项。

iOS 照片库
iOS 8以后使用PHPhotoLibrary框架可以访问相册中的资源

iOS iPod库
使用MediaPlayer框架可以访问iPod库中的音乐文件。例如访问歌手Foo Fighter的In Your Honor中的Best of you音频资源。

- (void)searchAideoInIPod {
    MPMediaPropertyPredicate *artistPredicate = [MPMediaPropertyPredicate predicateWithValue:@"Foo Fighrers" forProperty:MPMediaItemPropertyArtist];
    MPMediaPropertyPredicate *albumPredicate = [MPMediaPropertyPredicate predicateWithValue:@"In Your Honor" forProperty:MPMediaItemPropertyAlbumTitle];
    MPMediaPropertyPredicate *songPredicate = [MPMediaPropertyPredicate predicateWithValue:@"Best of you" forProperty:MPMediaItemPropertyTitle];
    
    MPMediaQuery *query = [[MPMediaQuery alloc] init];
    [query addFilterPredicate:artistPredicate];
    [query addFilterPredicate:albumPredicate];
    [query addFilterPredicate:songPredicate];
    
    NSArray *results = [query items];
    if (results.count > 0) {
        MPMediaItem *item = results.firstObject;
        NSURL *assetURL = [item valueForProperty:MPMediaItemPropertyAssetURL];
        AVAsset *asset = [AVAsset assetWithURL:assetURL];
        // Handle asset
    }
}

Mac iTunes库
在OS X上,从Mac OS X 10.8和iTunes 11.0开始,可以使用iTunesLibrary框架获取资源。

ITLibrary *library = [ITLibrary libraryWithAPIVersion:@"1.0" error:nil];

NSArray *items = self.library.allMediaItems;
NSString *query = @"artist.name == 'Robert' AND album.title == 'King' AND title == 'Cross' ";
NSPredicate *predicate = [NSPredicate predicateWithFormat:query];

NSArray *songs = [items filteredArrayUsingPredicate:predicate];
if (songs.count > 0) {
    ITLibMediaItem *item = songs[0];
    AVAsset *asset = [AVAsset assetWithURL:item.location];
    // Handle asset
}
4.1.3 异步加载属性

AVAsset的很多属性如tracks,duration、availableMetaDataFormats等属性只有在请求时才会载入。在主线程中直接使用会存在风险,如调用duration时如果元数据中不含对应标签,那就需要查看所有媒体资源文件最后确定时间,这种耗时操作会阻塞主线程,因此需要延时加载。使用时通常在初始化时直接调用第二个方法加载常用的属性,在其回调中使用第一个方法判断加载状态。

// 两个方法中的key使用属性相同的字符串即可,需要注意的是第二个方法的回调可能在任意线程执行,因此如果有UI更新操作,需回调回主线程执行。
// 确定是否完成加载
- (AVKeyValueStatus)statusOfValueForKey:(NSString *)key error:(NSError * _Nullable * _Nullable)outError;

// 异步加载某组属性
- (void)loadValuesAsynchronouslyForKeys:(NSArray<NSString *> *)keys completionHandler:(nullable void (^)(void))handler;

4.2 媒体元数据

媒体元数据指的是描述媒体资源的信息,如时长,艺术家等。AVAsset和AVASeetTrack都有属于自己的元数据。

4.2.1 元数据的格式

在不同的容器中,元数据组织的方式并不相同,在Apple中会遇到的媒体类型有QuickTime(mov)、MPEG-4 video(mp4和m4v)、MPEG-4 audio(m4a)和MPEG-Layer III audio(mp3)。尽管其元数据组织方式不同,但是Apple提供了通用的接口,这里只了解即可。可以以16进制文件方式打开查看。

QuickTime
QuickTime是苹果公司开发的一种标准,主要由多个atom组成,一个atom包含了描述媒体某一方面的数据,也可以包含其他atom。atom不仅包含元数据,还详细的描述了布局、格式、视频帧等信息。

QuickTime中元数据有两部分,一部分位于/moov/meta/ilst中,描述标题等信息,另外一部分位于/moov/udta,描述了版权等信息。具体可见批警告官方文档QuickTime File Format Specification。

MPEG-4 音频和视频
MP4直接派生与QuickTime格式,它的元数据保存在/moov/udta/meta/list中,具体信息可以在mp4v2库中查看。

MP3
MP3不使用容器格式,直接使用编码音频数据,元数据位于文件开头。它以ID3格式的第二个大版本ID3V2和其子版本的格式保存元数据。其中包含演唱者,所属唱片和音乐风格等信息。ID3有一个头部,分为5段,分别为ID3,版本,版本号,标志,大小组成。由于受MP3专利限制,AVFoundation只能对其解码,不能对其编码。ID3主体有多个frame组成,每个帧由头部和躯干构成,头部包括标题、大小和标志,躯干包括可选标志和数据。

4.2.2 使用元数据

查找元数据
AVFoundation中AVMetadataItem的实例代表了一条元数据。AVAsset和AVAssetTrack都可以使metadataForFormat获取元数据。通常区别于要获取的元数据类型,有两种具体处理方案。注意: commonMetadata 和metadata都必须异步加载。1)对于一些常见的通用的元数据使用commonMetadata获取通用的元数据对象数组, 再通过AVMetadataCommonIdentifierArtist等标识符过滤该数组得到想要的对象数组。2)对于不确定的元数据,可以使用metadata获取元数据对象数组,在通过AVMetadataIdentifierID3MetadataEncodedWith等标识符过滤得到想要的对象数组。

使用AVMetadataItem
AVMetadataItem实例包含key和value两个属性,并且都被定义为id<NSObject, NSCopying>类型。当知道Value的具体类型时,可以使用stringValue、numberValue、dataValue等属性直接获取。但是key值如果直接打印会得到一个整数,此时我们需要将其转换为字符串,方法如下。

@implementation AVMetadataItem (CJUtility)
- (NSString *)keyString {
    // 只有极少数情况会直接返回字符串
    if ([self.key isKindOfClass:[NSString class]]) {
        return (NSString *)self.key;
    } else if ([self.key isKindOfClass:[NSNumber class]]) {
        UInt32 keyValue = [(NSNumber *)self.key unsignedIntValue];
        // 大多数情况下元数据的键值都是由4个字符构成,但是ID3v2.2是由3个字符构成,为了统一处
        // 理,定义为4个字符,如果不够则用0补齐二进制位。
        size_t length = sizeof(UInt32);
        
        // 判断是否为4个字符,由于不足4个字符时右侧以二进制位0补齐,因此通过右移计算出真实字符个数
        if ((keyValue >> 24) == 0) --length;
        if ((keyValue >> 16) == 0) --length;
        if ((keyValue >> 8) == 0) --length;
        if ((keyValue >> 0) == 0) --length;
        
        // 确定内存地址的首地址
        long address = (unsigned long)&keyValue;
        // 由于大小端转换后,所有不足的用0补齐的二进制位被放到了低地址位,因此需要将起始地址指针向右移动
        address += (sizeof(UInt32) - length);
        
        // 由于数字是以大端模式保存的,而Intel的CPU是小端模式,因此当手动调用c接口将其对应的连续地址转
        // 化为字符串时需进行转换。
        keyValue = CFSwapInt32BigToHost(keyValue);
    
        char csstring[length];
        strncpy(csstring, (char *)address, length);
        csstring[length] = '\0';
        
        // 将所有的©字符替换为@字符
        if (csstring[0] == '\xA9') {
            csstring[0] = '@';
        }
        return [NSString stringWithCString:(char *)csstring encoding:NSUTF8StringEncoding];
    } else {
        return @"unKnown";
    }
}
@end
4.2.3 创建元数据读取和编辑器

创建元数据读取和编辑器的范例移步至示例.

5 视频播放

5.1 AVFoundation视频播放部分简介

AVFoundation视频播放相关类中主要由AVAsset、AVAssetTrack、AVPlayerItem、AVPlayerItemTrack、AVPlayerLayer组成,他们之间的关系如下。


AVFoundation视频播放核心类

AVPlayer
AVPlayer负责播放媒体资源,支持播放从本地、分步下载或者通过HTTP Live Streaming协议得到的流媒体。它是一个不可见组件,需要将视频资源可视化显示的时候需要用到AVPlayerLayer。当需要在一个序列中播放多个条目或者为媒体资源设置播放循环时可以使用其子类AVQueuePlayer。

AVPlayerLayer
AVPlayerLayer构建于Core Animation之上。Core Animation是Apple用于图形渲染与动画基础框架。它基于OpenGL,具有很高的性能。AVPlayerLayer扩展了Core Animation的CALayer类,可以以可视化的界面展示视频资源。它只有一个video gravity的属性可以自定义,其控制视频图层的拉伸状态,通常使用默认值ResizeAspect。

AVPlayerItem
由于AVAsset只包含媒体资源的静态信息,当需要播放媒体资源时,需要使用AVPlayerItem和AVPlayerItemTrack类构建动态模型。AVPlayerItem会建立媒体资源动态视角的数据模型。这个类中通常可以见到seekToTime等方法实现基于时间的操作。AVPlayerItemTrack和基层的AVAssetTrack一一对应。

在初始化完成AVPlayer后并不能直接播放媒体资源,需要等到AVPlayerItem对象准备好后才能播放。要知道其准备状态只能通过KVO的方式监听其status属性,在其创建之初这个属性是UnKnown,只有当其变为ReadyToPlay后才能真正播放视频。

CMTime
AVFoundation是基于Core Media的高级封装,Core Media是基于C的地层框架,其提供了一个时间数据结构CMTime,其Value是一个64位整数,Timescale是一个32位整数,具体时间是他们的商value/Timescale。

5.1 视频播放器

5.1.1 视频播放

1)初始化渲染视图THPlayerView渲染视频内容

@interface THPlayerView ()
// 负责交互界面渲染的视图
@property (strong, nonatomic) THOverlayView *overlayView;
@end

@implementation THPlayerView
+ (Class)layerClass {
    return [AVPlayerLayer class];
}

- (id)initWithPlayer:(AVPlayer *)player {
    if (self = [super initWithFrame:CGRectZero]) {
        self.backgroundColor = [UIColor blackColor];
        self.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
        // 关联播放器
        [(AVPlayerLayer *)[self layer] setPlayer:player];
        // 初始化控制视图,此处用Nib,真实开发建议使用代码,方便维护
        [[NSBundle mainBundle] loadNibNamed:@"THOverlayView" owner:self options:nil];
        [self addSubview:_overlayView];
    }
    return self;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    self.overlayView.frame = self.bounds;
}

- (id <THTransport>)transport {
    return self.overlayView;
}
@end

2)制定协议THTransportDelegate规定交互视图的代理(通常是控制器)需要实现的方法,制定协议THTransport规定交互视图需要实现的属性和方法。

// 控制视图的代理需要遵守的协议,定义了控制视图代理需要实现的方法,负责具体的逻辑实现
@protocol THTransportDelegate <NSObject>
- (void)play;
- (void)pause;
- (void)stop;

- (void)scrubbingDidStart;
- (void)scrubbedToTime:(NSTimeInterval)time;
- (void)scrubbingDidEnd;

- (void)jumpedToTime:(NSTimeInterval)time;

@optional
- (void)subtitleSelected:(NSString *)subtitle;
@end

// 控制视图需要遵守的协议,定义了控制视图需要实现的方法,负责接收用户的操作逻辑
@protocol THTransport <NSObject>
@property (weak, nonatomic) id <THTransportDelegate> delegate;

- (void)displayMediaTitle:(NSString *)title;
- (void)displayCurrentTime:(NSTimeInterval)time duration:(NSTimeInterval)duration;
- (void)displayScrubbingTime:(NSTimeInterval)time;
- (void)playbackComplete;
- (void)displaySubtitles:(NSArray *)subtitles;
@end

3)初始化遵守THTransport协议的交互视图THOverlayView

4)初始化播放控制器THPlayerController。完成这四步就可以播放视频。

@interface THPlayerController () <THTransportDelegate>

@property (strong, nonatomic) AVAsset *asset;
@property (strong, nonatomic) AVPlayerItem *playerItem;
@property (strong, nonatomic) AVPlayer *player;
@property (strong, nonatomic) THPlayerView *playerView;
// 尽管控制界面transport保存在playerview中,但是由于其会和playercontroller之间有大量通信,因此单独将其保存为一个弱引用属性,减少沟通成本
@property (weak, nonatomic) id<THTransport> transport;

@property (strong, nonatomic) id timeObserver;
@property (strong, nonatomic) id itemEndObserver;
@property (assign, nonatomic) float lastPlaybackRate;

@property (strong, nonatomic) AVAssetImageGenerator *imageGenerator;
@end

@implementation THPlayerController
#pragma mark - Setup
- (id)initWithURL:(NSURL *)assetURL {
    if (self = [super init]) {
        _asset = [AVAsset assetWithURL:assetURL];
        [self prepareToPlay];
    }
    return self;
}

- (void)dealloc {
    [self removeItemEndObserverIfNeeded];
}

// 初始化播放器
- (void)prepareToPlay {
    NSArray *keys = @[@"tracks", @"duration", @"commonMetadata", @"availableMediaCharacteristicsWithMediaSelectionOptions"];
    // iOS7后该方法将使得只有AVPlayerItem绑定的AVAsset载入keys中的属性时,AVPlayerItem的状态才会被置为readyToPlay
    self.playerItem = [AVPlayerItem playerItemWithAsset:self.asset automaticallyLoadedAssetKeys:keys];
    [self.playerItem addObserver:self forKeyPath:STATUS_KEYPATH options:0 context:&PlayerItemStatusContext];
    self.player = [AVPlayer playerWithPlayerItem:self.playerItem];
    
    self.playerView = [[THPlayerView alloc] initWithPlayer:self.player];
    self.transport = self.playerView.transport;
    self.transport.delegate = self;
}

// 观察PlayerItem状态,决定是否播放
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == &PlayerItemStatusContext) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.playerItem removeObserver:self forKeyPath:STATUS_KEYPATH];
            if (self.playerItem.status == AVPlayerItemStatusReadyToPlay) {
                // 添加时间监听者,内部涉及添加通知,这里每个控制器只会播放一个条目,在实际开发播放多个条目时,每次播放新条目前都需移除旧的通知。
                [self addPlayerItemTimeObserver];
                [self addItemEndObserverForPlayerItem];
                
                CMTime duration = self.playerItem.duration;
                // 初始化展示时间信息
                [self.transport setCurrentTime:CMTimeGetSeconds(kCMTimeZero) duration:CMTimeGetSeconds(duration)];
                // 设置视频标题
                [self.transport setTitle:self.asset.title];
                
                [self.player play];
                [self generateThumbnails];
                // 载入字幕
                [self loadMediaOptions];
            }
        });
    } else {
        [UIAlertView showAlertWithTitle:@"Error" message:@"Failed to load video"];
    }
}

#pragma mark - Housekeeping
- (UIView *)view {
    return self.playerView;
}
@end
5.1.2 时间监听

AVPlayer的时间变化不能使用KVO监听,AVFoundation在AVPlayer类中提供了两个方式准确监听媒体播放的时间进度监听。另外AVFoundation还提供一个通知AVPlayerItemDidPlayToEnd...,该通知在playerItem播放到末尾时候会调用。

1)定期监听:通过调用AVPlayer的addPeriodicTimeObserver...方法指定一个时间间隔interv,指定一个回调调用的队列queue,和一个回调block来监听播放器的时间改变,该返回值必须被播放器强持有,以待后期删除监听时使用。

2)边界监听:通过调用AVPlayer的addBoundaryTimeObserver...方法指定一个边界点时间数组times,指定一个回调调用的队列queue,和一个回调block来监听播放器的时间改变。

3)播放结束监听:通过监听AVPlayerItemDidPlayToEnd...获取单个AVPlayerItem结束事件。

#pragma mark - Time Observers
// 周期性监听播放器时间改变
- (void)addPlayerItemTimeObserver {
    CMTime interval = CMTimeMakeWithSeconds(REFRESH_INTERVAL, NSEC_PER_SEC);
    // 定义消回调在主队列中执行
    dispatch_queue_t queue = dispatch_get_main_queue();
    __weak __typeof(self) weakself = self;
    void (^callback)(CMTime time) = ^(CMTime time) {
        NSTimeInterval currentTime = CMTimeGetSeconds(time);
        NSTimeInterval duration = CMTimeGetSeconds(weakself.playerItem.duration);
        [weakself.transport setCurrentTime:currentTime duration:duration];
    };
    // 必须强持有请求周期监听时间返回的指针,它也用于移除监听器
    self.timeObserver = [self.player addPeriodicTimeObserverForInterval:interval queue:queue usingBlock:callback];
}

- (void)addItemEndObserverForPlayerItem {
    NSString *name = AVPlayerItemDidPlayToEndTimeNotification;
    NSOperationQueue *queue = [NSOperationQueue mainQueue];
    __weak __typeof(self) weakself = self;
    void (^callback) (NSNotification *note) = ^(NSNotification *notification) {
        [weakself.player seekToTime:kCMTimeZero completionHandler:^(BOOL finished) {
            [weakself.transport playbackComplete];
        }];
    };
    self.itemEndObserver = [[NSNotificationCenter defaultCenter] addObserverForName:name object:self.playerItem queue:queue usingBlock:callback];
}

// 从通知中心移除监听记录,由于每次添加通知,通知中心都会维护一个表,因此当某个监听失效时要及时手动移除,本次案例一个播放器只播放单个条目,在播放多个条目时必须及时在监听失效及时移除。
- (void)removeItemEndObserverIfNeeded {
    if (self.itemEndObserver) {
        // 确保此处的object和添加通知时的Object相对应
        [[NSNotificationCenter defaultCenter] removeObserver:self.itemEndObserver name:AVPlayerItemDidPlayToEndTimeNotification object:self.player.currentItem];
        self.itemEndObserver = nil;
    }
}
5.1.3 交互界面逻辑关联
#pragma mark - THTransportDelegate Methods
- (void)play {
    [self.player play];
}

- (void)pause {
    self.lastPlaybackRate = self.player.rate;
    [self.player pause];
}

- (void)stop {
    [self.player setRate:0.0f];
    // 展示面板完善相关逻辑方法
    [self.transport playbackComplete];
}

- (void)scrubbingDidStart {
    self.lastPlaybackRate = self.player.rate;
    [self.player pause];
    // 开始拖动进度条时移除时间监听,不希望手动操作进度条时时间监听再回馈操作
    [self.player removeTimeObserver:self.timeObserver];
}

- (void)scrubbedToTime:(NSTimeInterval)time {
    // 由于切换到某个进度是耗时的,为了避免操作堆积,当发出新的切换请求时撤销前面所有未完成操作
    [self.playerItem cancelPendingSeeks];
    [self.player seekToTime:CMTimeMakeWithSeconds(time, NSEC_PER_SEC)];
}

- (void)scrubbingDidEnd {
    [self addPlayerItemTimeObserver];
    if (self.lastPlaybackRate > 0.0f) {
        [self.player play];
    }
}
5.1.4 创建可视化缩略图

AVAssetImageGenerator定义了两个方法可以从本地媒体资源,和正在下载的媒体资源创建缩略图,但是注意其不支持从HTTP Live Stream生成图片。注意从本地资源生成一组图片可以等图片生成完毕后进行可视化操作,但是当需要生成大量图片,如从远程资源生成图片时,应该将要生成的图片分组,当生成一部分后就及时可视化显示,提高用户体验。

1)copyCGImageAtTime:...:支持获取某一时刻的单帧图片。

2)generateCGImagesAsynchronouslyForTimes:...:支持获取一组时刻的缩略图。

#pragma mark - Thumbnail Generation
- (void)generateThumbnails {
    self.imageGenerator = [AVAssetImageGenerator assetImageGeneratorWithAsset:self.asset];
    // 根据视频原尺寸自动适配为200像素宽的缩略图
    self.imageGenerator.maximumSize = CGSizeMake(200.0f, 0.0f);
    CMTime duration = self.asset.duration;
    
    NSMutableArray *times = [NSMutableArray arrayWithCapacity:5];
    CMTimeValue increment = duration.value / 20;
    CMTimeValue currentValue = kCMTimeZero.value;
    while (currentValue <= duration.value) {
        CMTime time = CMTimeMake(currentValue, duration.timescale);
        [times addObject:[NSValue valueWithCMTime:time]];
        currentValue += increment;
    }
    
    __block NSUInteger imageCount = times.count;
    __block NSMutableArray *images = [NSMutableArray arrayWithCapacity:5];
    
    // 可以根据requestedTimeToleranceBefor和requestTimeToleranceAfter来调整requestedTime和actualTime的接近程度
    AVAssetImageGeneratorCompletionHandler handler = ^(CMTime requestedTime, CGImageRef imageRef, CMTime actualTime, AVAssetImageGeneratorResult result, NSError *error) {
        // CGImageRef属于Core Foundation,但是由于其由系统创建,因此此处不用管理内存释放
        if (result == AVAssetImageGeneratorSucceeded) {
            UIImage *image = [UIImage imageWithCGImage:imageRef];
            id thumbnail = [THThumbnail thumbnailWithImage:image time:actualTime];
            [images addObject:thumbnail];
        } else {
            NSLog(@"Failed to create thumbnail image.");
        }
        
        if (--imageCount == 0) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [[NSNotificationCenter defaultCenter] postNotificationName:THThumbnailsGeneratedNotification object:images];
            });
        }
    };
    [self.imageGenerator generateCGImagesAsynchronouslyForTimes:times completionHandler:handler];
}

// 点击缩略图,跳转播放某一刻的画面
- (void)jumpedToTime:(NSTimeInterval)time {
    [self.player seekToTime:CMTimeMakeWithSeconds(time, NSEC_PER_SEC)];
}
5.1.5 显示字幕

AVAsset中除了保存有主视频轨道和主音频轨道外,还保存有备用媒体轨道,可以通过其availableMediaCharacteristicsWith...获取。该方法返回了一个字符串数组,可能的值的常量是AVMediaCharateristicVisual(备用视频轨道)、AV...Auduble(备用音频轨道)、AV...Legible(备用字幕轨道)。调用AVAsset的mediaSelectionGroupFor...可以获取指定类型的备用轨道数组,有AVMediaSelectionOption构成。调用AVPlayerItem的selectMeidaOption...可以设置需要渲染的备用轨道,此时播放器会自动在AVPlayerLayer上渲染这些元素。

- (void)loadMediaOptions {
    NSString *mc = AVMediaCharacteristicLegible;
    AVMediaSelectionGroup *group = [self.asset mediaSelectionGroupForMediaCharacteristic:mc];
    if (group) {
        NSMutableArray *subtitles = [NSMutableArray arrayWithCapacity:5];
        for (AVMediaSelectionOption *option in group.options) {
            [subtitles addObject:option.displayName];
        }
        [self.transport setSubtitles:subtitles];
    } else {
        [self.transport setSubtitles:nil];
    }
}

- (void)subtitleSelected:(NSString *)subtitle {
    NSString *mc = AVMediaCharacteristicLegible;
    AVMediaSelectionGroup *group = [self.asset mediaSelectionGroupForMediaCharacteristic:mc];
    BOOL selected = NO;
    for (AVMediaSelectionOption *option in group.options) {
        if ([option.displayName isEqualToString:subtitle]) {
            [self.playerItem selectMediaOption:option inMediaSelectionGroup:group];
            selected = YES;
        }
    }
    // 当用户选择None时候移除字幕
    if (!selected) {
        [self.playerItem selectMediaOption:nil inMediaSelectionGroup:group];
    }
}
5.1.6 AirPlay

Apple在系统层级上提供AirPlay功能,但是也支持在应用程序内使用AirPlay功能,此处需要使用到Media Player框架中的MPVolumeView类,只需要将这个类的实例做出部分自定义设置后展示在视图上即可。

- (void)enableAirplay {
    UIImage *airplayImage = [UIImage imageNamed:@"airplay"];
    self.volumeView = [[MPVolumeView alloc] initWithFrame:CGRectZero];
    self.volumeView.showsVolumeSlider = NO;
    self.volumeView.showsRouteButton = YES;
    [self.volumeView setRouteButtonImage:airplayImage forState:UIControlStateNormal];

    [self.volumeView sizeToFit];
    // 只需要展示这个Item即可
    UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:self.volumeView];
}
5.1.7 PictureInPicture

PictureInPicture是iOS9提供的新功能,可用通过AVPlayerLayer创建一个AVPictureInPictureController完成,具体参考

6 AV Kit

AVkit是iOS和Mac OS X中通用的高级框架,它基于AVFoundation,但是在不同环境下在用法上有一定的区别。

6.1 iOS中的AV Kit

在iOS中,AV Kit框架只包含一个AVPlayerViewController类,它只有以下几个公开属性。

  • player:管理媒体资源的播放器,必须在创建实例的时候手动指定。
  • showPlaybackControls:表示是否隐藏或显示播放控件。
  • videoGravity:设置画面缩放方式。
  • readyForDisplay:表示视频内容是否已经准备好进行展示。
NSURL *url = [[NSBundle mainBundle] URLForResource:@"basketball" withExtension:@"mov"];
AVPlayerViewController *controller = [[AVPlayerViewController alloc] init];
controller.player = [AVPlayer playerWithURL:url];
[self presentViewController:controller animated:YES completion:^{
    [controller.player play];
}];

6.2 Mac OS X中的AV Kit

AVPlayerView负责播放业务实现,提供几种类型控制条供选择,floating模式和Quick Time播放器类似。

6.2.1 播放视频
- (void)windowControllerDidLoadNib:(NSWindowController *)controller {
    [super windowControllerDidLoadNib:controller];
    [self setupPlaybackStackWithURL:[self fileURL]];
}

- (void)setupPlaybackStackWithURL:(NSURL *)url {
    self.asset = [AVAsset assetWithURL:url];
    NSArray *keys = @[@"commonMetadata", @"availableChapterLocales"];
    self.playerItem = [AVPlayerItem playerItemWithAsset:self.asset automaticallyLoadedAssetKeys:keys];
    
    [self.playerItem addObserver:self forKeyPath:STATUS_KEY  options:0 context:NULL];
    self.playerView.player = [AVPlayer playerWithPlayerItem:self.playerItem];
    self.playerView.showsSharingServiceButton = YES;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:STATUS_KEY]) {
        if (self.playerItem.status == AVPlayerItemStatusReadyToPlay) {
            // 获取视频资源的标题
            NSString *title = [self titleForAsset:self.asset];
            if (title) {
                self.windowForSheet.title = title;
            }
            // 获取章节书签, 为控制栏添加章节控制按钮,在floating模式下会显示
            self.chapters = [self chaptersForAsset:self.asset];
            if (self.chapters.count > 0) {
                [self setupActionMenu];
            }
        }
        [self.playerItem removeObserver:self forKeyPath:STATUS_KEY];
        //self.playerView.controlsStyle = AVPlayerViewControlsStyleInline;
    }
}
6.2.2 获取视频标题
- (NSString *)titleForAsset:(AVAsset *)asset {
    NSString *title = [self titleInMetadatas:asset.commonMetadata];
    if (![title isEqualToString:@""]) {
        return title;
    }
    return nil;
}

- (NSString *)titleInMetadatas:(NSArray *)metadatas {
    NSArray *items = [AVMetadataItem metadataItemsFromArray:metadatas filteredByIdentifier:AVMetadataCommonIdentifierTitle];
    return [items.firstObject stringValue];
}
6.2.3 处理章节信息
- (NSArray *)chaptersForAsset:(AVAsset *)asset {
    NSArray *languages = [NSLocale preferredLanguages];
    NSArray *metadataGroups = [asset chapterMetadataGroupsBestMatchingPreferredLanguages:languages];
    NSMutableArray *chapters = [NSMutableArray array];
    for (NSInteger i = 0; i < metadataGroups.count; i++) {
        AVTimedMetadataGroup *group = metadataGroups[i];
        CMTime time = group.timeRange.start;
        NSUInteger number = i+1;
        NSString *title = [self titleInMetadatas:group.items];
        THChapter *chapter = [THChapter chapterWithTime:time number:number title:title];
        [chapters addObject:chapter];
    }
    return chapters;
}

- (void)setupActionMenu {
    NSMenu *menu = [[NSMenu alloc] init];
    [menu addItem:[[NSMenuItem alloc] initWithTitle:@"Previous Chapter" action:@selector(previousChapter:) keyEquivalent:@""]];
    [menu addItem:[[NSMenuItem alloc] initWithTitle:@"Next Chapter" action:@selector(nextChapter:) keyEquivalent:@""]];
    self.playerView.actionPopUpButtonMenu = menu;
}

- (void)previousChapter:(id)sender {
    [self skipToChapter:[self findPreviousChapter]];
}

- (void)nextChapter:(id)sender {
    [self skipToChapter:[self findNextChapter]];
}

- (THChapter *)findPreviousChapter {
    CMTime playerTime = self.playerItem.currentTime;
    // 从当前时间查找上一个章节会是本章节开头,会陷入死循环,因此对时间容错处理
    CMTime currentTime = CMTimeSubtract(playerTime, CMTimeMake(3, 1));
    CMTime pastTime = kCMTimeNegativeInfinity;
    // 此处的TimeRange的start表示起始时间,duration表示结束时间
    CMTimeRange timeRange = CMTimeRangeMake(pastTime, currentTime);
    return [self findChapterInTimeRange:timeRange reverse:YES];
}

- (THChapter *)findNextChapter {
    CMTime currentTime = self.playerItem.currentTime;
    CMTime futureTime = kCMTimePositiveInfinity;
    CMTimeRange timeRange = CMTimeRangeMake(currentTime, futureTime);
    return [self findChapterInTimeRange:timeRange reverse:NO];
}

- (THChapter *)findChapterInTimeRange:(CMTimeRange)timeRange reverse:(BOOL)reverse {
    __block THChapter *matchingChapter = nil;
    NSEnumerationOptions options = reverse ? NSEnumerationReverse : 0;
    [self.chapters enumerateObjectsWithOptions:options usingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([(THChapter *)obj isInTimeRange:timeRange]) {
            matchingChapter = obj;
            *stop = YES;
        }
    }];
    return matchingChapter;
}

- (void)skipToChapter:(THChapter *)chapter {
    [self.playerItem seekToTime:chapter.time completionHandler:^(BOOL finished) {
        [self.playerView flashChapterNumber:chapter.number chapterTitle:chapter.title];
    }];
}
6.2.4 裁剪视频资源
- (void)setupActionMenu {
    NSMenu *menu = [[NSMenu alloc] init];
    if (self.playerView.canBeginTrimming) {
        [menu addItem:[[NSMenuItem alloc] initWithTitle:@"Trim" action:@selector(trim:) keyEquivalent:@""]];
    }
    self.playerView.actionPopUpButtonMenu = menu;
}

- (void)trim:(id)sender {
    [self.playerView beginTrimmingWithCompletionHandler:NULL];
}

由于AVAsset是不可变的,上述方法只是通过改变播放条目的reversePlaybackEndtime和forwardPlaybackEndTime来修改有效时间轴,要真正裁剪视频资源必须导出裁剪后的资源。

- (void)startExporting:(id)sender {
    [self.playerView.player pause];
    
    NSSavePanel *savePanel = [NSSavePanel savePanel];
    [savePanel beginSheetModalForWindow:self.windowForSheet completionHandler:^(NSModalResponse result) {
        if (result == NSFileHandlingPanelOKButton) {
            [savePanel orderOut:nil];
            
            NSString *preset = AVAssetExportPresetAppleM4V720pHD;
            self.exportSession = [[AVAssetExportSession alloc] initWithAsset:self.asset presetName:preset];
            CMTime startTime = self.playerItem.reversePlaybackEndTime;
            CMTime endTime = self.playerItem.forwardPlaybackEndTime;
            CMTimeRange timeRange = CMTimeRangeMake(startTime, endTime);
            
            self.exportSession.timeRange = timeRange;
            self.exportSession.outputFileType = [self.exportSession.supportedFileTypes firstObject];
            self.exportSession.outputURL = savePanel.URL;
            
            // 显示进度控制器
            self.exportController = [[THExportWindowController alloc] init];
            self.exportController.exportSession = self.exportSession;
            self.exportController.delegate = self;
            [self.windowForSheet beginSheet:self.exportController.window completionHandler:nil];
            [self.exportSession exportAsynchronouslyWithCompletionHandler:^{
                [self.windowForSheet endSheet:self.exportController.window];
                self.exportController = nil;
                self.exportSession = nil;
            }];
        }
    }];
}
6.2.5 适配老旧QuickTime编码格式

QuickTime文件容器支持多种编码标准和媒体类型,但是AVFoundation对部分老旧的编码格式并不支持,此时需要使用QTKit框架中的QTMovieModernizer进行转换。

- (BOOL)readFromURL:(NSURL *)url ofType:(NSString *)typeName error:(NSError *__autoreleasing *)outError {
    NSError *error = nil;
    // 该方法是耗时方法,实际项目中需要在后台执行此方法
    if ([QTMovieModernizer requiresModernization:url error:&error]) {
        self.modernizing = YES;
        NSURL *destURL = [self tempURLForURL:url];
        if (!destURL) {
            self.modernizing = NO;
            NSLog(@"Creating destination URL failed, skipping modernization.");
            return NO;
        }
        QTMovieModernizer *modernizer = [[QTMovieModernizer alloc] initWithSourceURL:url destinationURL:destURL];
        modernizer.outputFormat = QTMovieModernizerOutputFormat_H264;
        
        [modernizer modernizeWithCompletionHandler:^{
            if (modernizer.status == QTMovieModernizerStatusCompletedWithSuccess) {
                self.modernizing = NO;
                dispatch_async(dispatch_get_main_queue(), ^{
                    [self setupPlaybackStackWithURL:destURL];
                    [(id)self.windowForSheet hideConvertingView];
                });
            }
        }];
    }
    return YES;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,565评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,021评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,003评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,015评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,020评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,856评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,178评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,824评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,264评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,788评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,913评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,535评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,130评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,102评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,334评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,298评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,622评论 2 343

推荐阅读更多精彩内容