最近在项目中使用了AVPlayer自定义视频播放器,具体包括播放、暂停、进度拖动、监听缓存状态(缓存慢的时候转菊花)、全屏播放等功能,在这里总结一下基本实现方法。
什么是AVPlayer
AVPlayer是AVFoundation框架里面提供的类,专门用于播放音视频(支持本地和流媒体)。详情参考AVFoundation Programming Guide.
基本使用
播放音视频
NSURL * url = [NSURL URLWithString:self.currentSong.url];
AVPlayerItem * videoItem = [[AVPlayerItem alloc]initWithURL:url];
AVPlayer * player = [[AVPlayer alloc]initWithPlayerItem:songItem];
播放/暂停
[player play];
[player pause];
注意:由于视频刚开始播放的时候要先下载资源缓存下来,然后再进行解封装、解码、渲染播放等操作,所以立即调用play方法要等待一段时间才开始播放,更好的方法是在播放器转态变为AVPlayerStatusReadyToPlay时调用play方法。立即调用play方法有可能会黑屏一下子,就是这个原因。
KVO监听播放器播放当前资源时的状态
[self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
然后可以在KVO方法中获取其status的改变
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"status"]) {
switch (self.player.status) {
case AVPlayerStatusUnknown:
NSLog(@"未知状态,此时不能播放");
break;
case AVPlayerStatusReadyToPlay:
NSLog(@"准备完毕,可以播放");
[self.player play];
break;
case AVPlayerStatusFailed:
NSLog(@"加载失败,网络或者服务器出现问题");
break;
default:
break;
}
}
}
注意:AVPlayer一共有3种状态AVPlayerStatusUnknown、AVPlayerStatusReadyToPlay、AVPlayerStatusFailed。一般初始化player到播放都会经历
Unknown到ReadyToPlay这个过程,网络情况良好时可能不会出现Unknown状态的提示,网络情况差的时候Unknown的状态可能会持续比较久甚至可能不进入ReadyToPlay状态,针对这种情况我们要做特殊的处理。
巨坑:播放有些在线音频文件的时候,在 currentItem 进入AVPlayerStatusReadyToPlay状态时, 获取资源的总时长时item.duration 仍有可能等于 CMTime.indefinite,CMTimeGetSeconds(item.duration)方法获取总时长为nan, 计算播放进度的时候老是crash。后来干脆在addPeriodicTimeObserverForInterval:queue:usingBlock:方法中获取总时长。
另外,不要忘记在合适的时机移除观察者:
[self.player.currentItem removeObserver:self forKeyPath:@"status"];
如何实现播放视频的上一个/下一个切换
这里我们有两种方式可以实现,一种是替换当前的item,这种方式据说是有性能问题。
[player replaceCurrentItemWithPlayerItem:songItem];
另一种是使用AVPlayer的子类AVQueuePlayer来播放多个item,调用advanceToNextItem来播放下一个视频
NSArray * items = @[item1, item2, item3 ....];
AVQueuePlayer * queuePlayer = [[AVQueuePlayer alloc]initWithItems:items];
[queuePlayer advanceToNextItem];
巨坑:AVQueuePlayer默认的actionAtItemEnd会删掉当前播放的item。
监听播放进度更新UI
使用player的addPeriodicTimeObserverForInterval:queue:usingBlock:方法来监听播放器的进度
添加观察者:
id timeObserve = [self.player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 1.0) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
float current = CMTimeGetSeconds(time);
float total = CMTimeGetSeconds(songItem.duration);
if (current) {
weakSelf.progress = current / total;
weakSelf.playTime = [NSString stringWithFormat:@"%.f",current];
weakSelf.playDuration = [NSString stringWithFormat:@"%.2f",total]; }
}];
移除观察者:
if (timeObserve) {
[player removeTimeObserver:_timeObserve];
timeObserve = nil;
}
注意点:
方法传入一个CMTime结构体,每到一定时间都会回调一次,包括开始和结束播放
如果block里面的操作耗时太长,下次不一定会收到回调,所以尽量减少block的操作耗时
方法会返回一个观察者对象,当播放完毕时需要移除这个观察者
KVO监听数据缓冲进度
[self.player.currentItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
然后可以在KVO方法中获取其数据缓冲状态的改变
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
AVPlayerItem * songItem = object;
if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
NSArray * array = songItem.loadedTimeRanges;
CMTimeRange timeRange = [array.firstObject CMTimeRangeValue]; //本次缓冲的时间范围
NSTimeInterval totalBuffer = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration); //缓冲总长度
NSTimeInterval videoLength = CMTimeGetSeconds(songItem.duration);
CGFloat ratio = totalBuffer/videoLength;
NSLog(@"共缓冲%.2f",totalBuffer);
NSLog(@"缓冲比例%.2f", ratio);
}
}
如果你需要在进度条展示缓冲的进度,可以增加这个观察者。另外缓存的数据也可能是离散的。
同样也要做合适的时机移除观察者:
[songItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
KVO监听缓冲状态
item.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
item.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
playbackBufferEmpty 一般与 playbackLikelyToKeepUp 同时使用,二者均为Bool类型值。 playbackBufferEmpty 表示缓冲是否为空,为 true 时可以转菊花。playbackLikelyToKeepUp 表示缓冲是否可以足够播放, 为 true 时可以继续播放。
if path.elementsEqual("playbackBufferEmpty") {
if let playbackBufferEmpty = dict[NSKeyValueChangeKey.newKey] as? Bool {
PlayerLog("playbackBufferEmpty = \(playbackBufferEmpty)")
if playbackBufferEmpty {
playerStatus = .buffering
}
}
} else if path.elementsEqual("playbackLikelyToKeepUp") {
if let playbackLikelyToKeepUp = dict[NSKeyValueChangeKey.newKey] as? Bool {
PlayerLog("playbackLikelyToKeepUp = \(playbackLikelyToKeepUp)")
if playbackLikelyToKeepUp {
if queuePlayer?.rate == 0 {
queuePlayer?.play()
playerStatus = .prepareToPlay
}
}
}
}
定位播放点
要定位到指定时间点进行播放, 可以使用 seekToTime:如下:
CMTime fiveSecondsIn = CMTimeMake(5, 1);
[player seekToTime:fiveSecondsIn];
seekToTime:方法牺牲了精确度来优化性能, 如果需要精确定位, 使用 seekToTime:toleranceBefore:toleranceAfter:方法.
CMTime fiveSecondsIn = CMTimeMake(5, 1);
[player seekToTime:fiveSecondsIn toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
将容忍值设置为0意味着框架会解码大量的数据来保证精确度. 确保只有在真正需要的时候才会如此设置.
播放完毕之后, 播放点会被设置为item的结束点. 要将播放点重新设置到item开头, 可以注册接收AVPlayerItemDidPlayToEndTimeNotification通知, 在处理通知的方法中, 调用seekToTime:并传入参数kCMTimeZero.
监听播放完成
// 监听播放完成
NotificationCenter.default.addObserver(self, selector: #selector(itemFinished(_:)), name: .AVPlayerItemDidPlayToEndTime, object: queuePlayer?.currentItem ?? nil)
这里有个巨坑: 该通知会发送多次,尽管已经切换到下一首,Notification.object为播放的item,需要与当前item进行比较,以判断是否是当前正在播放的歌曲播放结束。另外,播放某些网络音频文件时,一开始播放就接收到AVPlayerItemDidPlayToEndTime通知,尴尬!遇到这种情况根本无法确定是否真正播放完成,以完成切换下一首的需求。目前用isPlaybackLikelyToKeepUp简单判断,亦可以用当前播放时间跟总时长比较,差值在一定范围类判断为播放完成。
最近发现使用原生AVPlayer进行资源加载后又没出现这个问题了, 推测使用AVPlayer+CacheSupport进行资源加载会导致这个问题。另外,AVPlayer+CacheSupport还会偶现有网情况下无法播放资源的情况。
// MARK: 播放完成
@objc private func itemFinished(_ notif: Notification) {
PlayerLog("播放完成通知!")
guard let currentItem = queuePlayer?.currentItem else { return }
if let item = notif.object as? AVPlayerItem, item == currentItem {
PlayerLog("\(currentItem.mc_URL) 播放完成!")
// 播放完成回到开始位置
updateProgress(0) { [weak self](ret) in
guard let strongSelf = self else {
self?.playerStatus = .finished
return
}
strongSelf.playerStatus = .finished
}
}
}