高仿网易云音乐播放器

网易云音乐凭着良好的交互体验,优质丰富的资源在终端一直有着不错的市场。相比较市面上的主流音乐播放器(QQ音乐、虾米音乐),笔者更倾向于云音乐的UED。

demo.gif

单从播放器页来说,云音乐的界面非常简洁,只保留了主要的操作功能,避免过多的信息造成视觉上的疲倦。主视图上不断旋转的黑胶唱片非常有带入感,左滑右滑切换唱片的设计也非常经典。

本文主要介绍了播放器实现的一些核心代码和思路,完整代码请通过文末链接自行clone

必要知识储备:

  • 基础数据结构知识(队列、栈、链表)
  • AVFoundation/AVPlayer
  • AVAudioSession
  • CoreAnimation
  • Autolayout(Masonry)
  • KVO、Notification

实现需求:

  • 播放器播放、暂停,上一首、下一首
  • 播放模式切换,支持单曲循环、顺序播放、随机播放
  • 黑胶唱片仿真动画
  • 后台播放及中断控制
  • 支持iOS10.0包括及以上系统

播放资源控制

音乐播放器的核心功能是针对播放资源的控制,其核心内容包括媒体资源状态控制、播放器状态控制(播放、暂停、切换)。

值得庆幸的是我们并不用自己去实现如在线资源拉取、资源缓存(如果有必要实现)、资源解码播放等复杂的底层功能,AVFoundation框架可以帮助我们完成这些事情,我们要做的是针对AVPlayer、AVPlayerItem提供的接口根据业务进行封装。

本文不介绍框架的详细使用,只针对具体使用业务场景提供封装思路及核心代码

AVPlayerItem封装

AVPlayerItemAVPlayer播放资源的基本单位,得益于OC语言良好的命名习惯,我们非常容易理解AVPlayerItem类的定义,它管理了媒体资源的地址时长缓存,并提供相关状态属性用于监听。下面代码块展示的是被封装的属性:

// AVPlayerItem.h

/**
订阅这个通知,当资源完成播放时该通知会被发出
*/
AVF_EXPORT NSString *const AVPlayerItemDidPlayToEndTimeNotification      NS_AVAILABLE(10_7, 4_0);  // item has played to its end time

/*!
@property status
@abstract
The ability of the receiver to be used for playback.
@discussion
The value of this property is an AVPlayerItemStatus that indicates whether the receiver can be used for playback.
When the value of this property is AVPlayerItemStatusFailed, the receiver can no longer be used for playback and a new instance needs to be created in its place. When this happens, clients can check the value of the error
property to determine the nature of the failure. This property is key value observable.

@translation
监听这个属性可以帮助我们获取当前资源的状态是否可以用于播放
*/

@property (nonatomic, readonly) AVPlayerItemStatus status;

/*!
@property loadedTimeRanges
@abstract This property provides a collection of time ranges for which the player has the media data readily available. The ranges provided might be discontinuous.
@discussion Returns an NSArray of NSValues containing CMTimeRanges.

@translation
这个属性将会返回当前资源的缓存进度
*/
@property (nonatomic, readonly) NSArray<NSValue *> *loadedTimeRanges;

值得关注的是,该类的多种状态响应使用了KVO、通知等方式发出,在接口阅读和使用上给我们带来了一定的困难,所以要将状态的监控再封装成我们熟悉的形式(delegate、block),并增加媒体播放的自定义参数:

// CMPlayerItem.h

@class CMPlayerItem;

@protocol CMPlayItemDelegate <NSObject>

@optional
/** 缓存进度 */
- (void)musicPlayerItem:(CMPlayerItem *)item bufferSeconds:(NSTimeInterval)seconds rate:(CGFloat)rate;
/** 资源状态 */
- (void)musicPlayerItem:(CMPlayerItem *)item playItemStatus:(AVPlayerItemStatus)status;
@end

@interface CMPlayerItem : AVPlayerItem

/** 名称 */
@property (nonatomic, copy) NSString *musicName;
/** 作者 */
@property (nonatomic, copy) NSString *musicAuthor;
/** 封面 */
@property (nonatomic, strong) NSURL *musicCoverURL;

/** 工厂方法 */
+ (instancetype)musicPlayItemWithURL:(NSURL *)URL name:(NSString *)name author:(NSString *)author coverURL:(NSURL *)coverURL;

/** 缓存时长(s) */
@property (nonatomic, assign, readonly) NSTimeInterval bufferSeconds;
/** 总时长(s) */
@property (nonatomic, assign, readonly) NSTimeInterval durationSeconds;

/** 资源状态代理 */
@property (nonatomic, weak) id<CMPlayItemDelegate> delegate;
@end


// CMPlayerItem.m

@implementation CMPlayerItem

+ (instancetype)musicPlayItemWithURL:(NSURL *)URL name:(NSString *)name author:(NSString *)author coverURL:(NSURL *)coverURL {
    CMPlayerItem *item = [self playerItemWithURL:URL];
    item.musicName = name;
    item.musicAuthor = author;
    item.musicCoverURL = coverURL;
    
    [item addObserver:item forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
    [item addObserver:item forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
    
    return item;
}

#pragma mark -

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
    if (!self.delegate) return;
    // 监听资源状态
    if ([keyPath isEqualToString:@"status"]) {
        if ([self.delegate respondsToSelector:@selector(musicPlayerItem:playItemStatus:)]) {
            [self.delegate musicPlayerItem:self playItemStatus:self.status];
        }
    // 监听资源下载进度
    } else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
        if ([self.delegate respondsToSelector:@selector(musicPlayerItem:bufferSeconds:rate:)]) {
            [self.delegate musicPlayerItem:self bufferSeconds:self.bufferSeconds rate:self.bufferSeconds / self.durationSeconds];
        }
    }
}

...
@end

这里我们利用了继承的特性,创建了一个符合业务需求的类,并使用了工厂方法提供了一个新的初始化器,用于初始化必要资源并添加监听。对比父类的接口,经过封装以后的API阅读起来是不是容易理解多了~

数据结构分析

在封装AVPlayer之前,先来分析一下播放器需求。

  • 播放器有三种播放模式,顺序、乱序、单曲循环。点击下一曲按钮要根据不同的播放模式获取曲目

  • 点击上一曲按钮能够播放最近已经播放过上一首的曲目

  • 滑动黑胶唱片能够切换上一曲或下一曲

通过对需求的分析,我们将业务转换为数据结构,播放器的播放资源组织可以通过一个特殊的队列结构进行管理,队列的长度为3,index=1指向播放中的数据,因为存在一个滑动唱片的操作设计,我们必须保证播放队列的内容长度永远为3,否则在滑动时再加载必然会造成页面卡顿。当切换下一曲时丢弃index=0的数据,从资源列表获取下一曲内容。但是这样的数据结构存在一个缺陷,当切换上一曲时,我们永远只能拿到上一次播放的资源。

聪明的同学会发现上一曲功能具有LIFO特性,很像一个栈建构。那么我们将之前的播放队列做一点改造,使用一个栈结构将下一曲切换时丢弃的曲目保存好,当需要上一曲切换时使用栈中的内容补充队列空缺。

playing.png

有了思路,我们将上一曲切换的流程转化为数据结构图并实现核心代码。首先播放队列丢弃index=2的曲目,并从播放栈中获取栈顶曲目插入播放队列index=0。当播放栈内容为空时,根据不同的播放模式从播放列表直接获取资源插入播放队列

prev.png

核心代码如下:

- (void)prev {
    ...
    
    // 丢弃播放队列最后一个元素
    [self.playQueue removeLastObject];
    CMPlayerItem *prevItem = [self.playedStack pop];
    
    // 当栈中内容为空
    if (!prevItem) {
        // 直接获取上一首资源
        prevItem = [self prevResourceWithPlayingItem:self.playQueue[CM_PLAYQUEUE_PREV_SOURCE]];
    }
    
    // 将资源插入播放队列
    [self.playQueue insertObject:prevItem atIndex:CM_PLAYQUEUE_PREV_SOURCE];
    
    // 更新播放器资源
    [self replaceCurrentItemWithPlayerItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]];
    [self replay];

    ...
}

下一曲切换操作和上一曲类似,只不过我们要改变一下数据流方向,将播放队列index=0内容丢至播放栈中,并从播放列表中根据当前模式插入播放队列index=2位置

next.png

核心代码如下:


- (void)next {
    ...
    
    // 将index=0内容放入播放栈
    [self.playedStack push:self.playQueue[CM_PLAYQUEUE_PREV_SOURCE]];
    [self.playQueue removeObjectAtIndex:CM_PLAYQUEUE_PREV_SOURCE];
    
    // 从播放列表补充下一首数据
    [self.playQueue addObject:[self nextResourceWithPlayingItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]]];
    
    // 更新播放器资源
    [self replaceCurrentItemWithPlayerItem:self.playQueue[CM_PLAYQUEUE_PLAYING_SOURCE]];
    [self replay];
    
    ...
}

AVPlayer封装

和封装AVPlayerItem思路一样,通过继承的方式我们创建一个新类CMPlayer,整合父类接口并用我们熟悉的方式暴露新的API,下面是.h文件代码

// CMPlayer.h

@class CMPlayer;
@class CMPlayerItem;

/**
 播放模式选择

 - CMPlayerModeLoop: 顺序
 - CMPlayerModeOne: 单曲循环
 - CMPlayerModeShuffle: 乱序
 */
typedef NS_ENUM(NSUInteger, CMPlayerMode) {
    CMPlayerModeLoop,
    CMPlayerModeOne,
    CMPlayerModeShuffle,
};

@protocol CMPlayerDelegate <NSObject>

@optional

/** 播放 */
- (void)musicPlayerStatusPlaying:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 暂停 */
- (void)musicPlayerStatusPaused:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 加载中 */
- (void)musicPlayerStatusLoading:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 完成 */
- (void)musicPlayerStatusComplete:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;

/** 下一首 */
- (void)musicPlayerStatusNext:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 上一首 */
- (void)musicPlayerStatusPrev:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;
/** 重播 */
- (void)musicPlayerStatusReplay:(CMPlayer *)player musicPlayerItem:(CMPlayerItem *)item;

/** 进度监听,间隔1s */
- (void)musicPlayerPlayingProgressCurrenSeconds:(NSTimeInterval)currentSec duration:(NSTimeInterval)durationSec buffer:(NSTimeInterval)bufferSec;

@end

@interface CMPlayer : AVPlayer

/**
 初始化播放器

 @param playList 播放列表
 @return 实例
 */
- (instancetype)initWithPlayList:(NSArray<CMPlayerItem *> *)playList;

/** 播放模式 */
@property (nonatomic, assign) CMPlayerMode playerMode;
/** 播放器代理 */
@property (nonatomic, weak) id<CMPlayerDelegate> delegate;

#pragma mark -
/** 播放列表 */
@property (nonatomic, strong) NSArray<CMPlayerItem *> *playList;
/** 播放队列 */
@property (nonatomic, readonly) NSArray *currentPlayingQueue;
/** 当前播放资源 */
@property (nonatomic, readonly) CMPlayerItem *currentMusicItem;

#pragma mark -
/** 当前播放时长 */
@property (nonatomic, readonly) NSTimeInterval currentSeconds;
/** 放回当前播放item.duration */
@property (nonatomic, readonly) NSTimeInterval durationSeconds;

#pragma mark -
/** 下一首 */
- (void)next;
/** 上一首 */
- (void)prev;

@end

.m文件具体实现代码请通过文末github链接自行clone,此处不再赘述

后台播放控制

允许后台播放

播放器需要在应用切换到后台时,保持音频播放能力。允许后台播放最简单的方式,是在Target - Capability - Background Modes中进行配置

Enabling Background Audio

参考文献:《Enabling Background Audio》

设置AVAudioSession

可以使用AVAudioSession告诉操作系统如何处理你的App音频流,而不需要和音频硬件产生直接的交互或者操作

Audio Session

设置AVAudioSessionCategory确定基本事件行为,再使用Mode Options两个属性对行为进行微调,下面是播放器设置源码:


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    ...
    
    // 设置并激活音频会话类别
    AVAudioSession *session = [AVAudioSession sharedInstance];
    // 设置Category只支持音频播放,打断其他不支持混音App
    [session setCategory:AVAudioSessionCategoryPlayback error:nil];
    // 启用session
    [session setActive:YES error:nil];
    
    ...
    return YES;
}

参考文献:《Audio Session Programming Guide》

远程控制媒体播放

我们还需支持在锁屏的媒体资源信息展示以及控制中心中对音乐的控制。我们可以使用Media Player框架的MPRemoteCommandCenterMPNowPlayingInfoCenter实现。

MPRemoteCommandCenter使用事件绑定的方式实现对远程事件的监听处理,每个控制事件都封装了一个MPRemoteCommand对象,为具体的事件添加响应方法:

// CMPlayer.m

- (void)handleRemoteControlEvent {
    MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
    // 播放
    [commandCenter.playCommand addTarget:self action:@selector(play)];
    // 暂停
    [commandCenter.pauseCommand addTarget:self action:@selector(pause)];
    // 上一首
    [commandCenter.previousTrackCommand addTarget:self action:@selector(prev)];
    // 下一首
    [commandCenter.nextTrackCommand addTarget:self action:@selector(next)];
    // 为耳机的按钮操作添加相关的响应事件
    [commandCenter.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
        if (self.timeControlStatus == AVPlayerTimeControlStatusPlaying) {
            [self pause];
        } else {
            [self play];
        }
        return MPRemoteCommandHandlerStatusSuccess;
    }];
}

MPNowPlayingInfoCenter是一个可以设置当前播放媒体需要展示信息的对象,这些信息会被展示到锁屏界面、控制中心等处,以下是简单用法:

// CMPlayer.m

- (void)configNowPlayingInfoCenter {
    NSMutableDictionary *nowPlayInfo = [[NSMutableDictionary alloc] init];
    // 歌曲名称
    [nowPlayInfo setObject:self.currentMusicItem.musicName forKey:MPMediaItemPropertyTitle];
    // 演唱者
    [nowPlayInfo setObject:self.currentMusicItem.musicAuthor forKey:MPMediaItemPropertyArtist];
    // 音乐剩余时长
    [nowPlayInfo setObject:@(self.currentMusicItem.durationSeconds) forKey:MPMediaItemPropertyPlaybackDuration];
    // 音乐当前播放时间
    [nowPlayInfo setObject:@(self.currentSeconds) forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
    
    [[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:nowPlayInfo];
}

参考文献:《Controlling Background Audio》

关于

本文为博主原创文章,项目资源均只供学习请勿商用,转载请附上博文链接!

Demo
个人博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,194评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,058评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,780评论 0 346
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,388评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,430评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,764评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,907评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,679评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,122评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,459评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,605评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,270评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,867评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,734评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,961评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,297评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,472评论 2 348

推荐阅读更多精彩内容