【iOS】教你用ZFPlayer+KTVHTTPCache搭建缓存,预加载的播放器

Demo演示的功能

提示:文末有相关的Demo下载链接

  • ZFPlayer的列表播放
  • 使用KTVHTTPCache实现缓存(播放过的视频无需再下载)
  • 使用KTVHTTPCache实现预加载(可以实现秒播)
  • 自定义转场动画(实现无缝衔接的播放效果)
  • 瀑布流页面(双排列表展示,以及转场动画)

gif演示:


playerDemo.gif

一、缓存+预加载功能

1、播放器mgr核心代码

mgr实现ZFPlayerMediaPlayback协议,然后在初始化时,开启本地服务器

+ (void)initialize
{
    [KTVHTTPCache logSetConsoleLogEnable:NO];
    NSError *error = nil;
    [KTVHTTPCache proxyStart:&error];
    if (error) {
        NSLog(@"Proxy Start Failure, %@", error);
    }
    [KTVHTTPCache encodeSetURLConverter:^NSURL *(NSURL *URL) {
//        NSLog(@"URL Filter reviced URL : %@", URL);
        return URL;
    }];
    [KTVHTTPCache downloadSetUnacceptableContentTypeDisposer:^BOOL(NSURL *URL, NSString *contentType) {
        return NO;
    }];
    // 设置缓存最大容量
    [KTVHTTPCache cacheSetMaxCacheLength:1024 * 1024 * 1024];
}

设置assetURL时,设置KTVHTTpCache为中间服务器,若该资源已缓存完毕,就无需代理,这个判断可以使已缓存的视频播放的更快

- (void)setAssetURL:(NSURL *)assetURL {
    if (self.player) [self stop];
    // 如果有缓存,直接取本地缓存
    NSURL *url = [KTVHTTPCache cacheCompleteFileURLWithURL:assetURL];
    if (url) {
        _assetURL = url;
    }else {
        // 设置代理
        _assetURL = [KTVHTTPCache proxyURLWithOriginalURL:assetURL];
    }
    [self prepareToPlay];
}

2、播放器Player核心代码

创建playableProtocol,方便数据管理

/// 只有实现该协议的模型才能预加载
@protocol XSTPlayable <NSObject>
/// string 视频链接
@property (nonatomic, copy) NSString *video_url;
@end

核心播放器为ZFPlayerController,为了方便管理,我们创建一个中间类包裹ZFPlayerController,且增加可以设置的预加载属性

@interface MPPlayerController : NSObject

// 预加载上几条
@property (nonatomic, assign) NSUInteger preLoadNum;
/// 预加载下几条
@property (nonatomic, assign) NSUInteger nextLoadNum;
/// 预加载的的百分比,默认10%
@property (nonatomic, assign) double preloadPrecent;
/// 设置playableAssets后,马上预加载的条数
@property (nonatomic, assign) NSUInteger initPreloadNum;
/// set之后,先预加载几个
@property (nonatomic, copy) NSArray<id<XSTPlayable>> *playableArray;
....

3、预加载核心代码

预加载的时机是当前视频可以播放了,才进行预加载

- (void)playTheIndexPath:(NSIndexPath *)indexPath playable: (id<XSTPlayable>)playable
{
    // 播放前,先停止所有的预加载任务
    [self cancelAllPreload];
    _currentPlayable = playable;
    [self.player playTheIndexPath:indexPath assetURL:[NSURL URLWithString:playable.video_url] scrollToTop:NO];
    __weak typeof(self) weakSelf = self;
    self.playerReadyToPlay = ^(id<ZFPlayerMediaPlayback>  _Nonnull asset, NSURL * _Nonnull assetURL) {
        [weakSelf preload: playable];
    };
}

预加载的规则是预加载当前视频的上2个,和下2个视频,逐个开启预加载,视频预加载(核心类KTVHCDataLoader)到10%就停止,然后开始下一个视频的预加载。这里要注意异步线程的操作,要加锁处理

/// 根据传入的模型,预加载上几个,下几个的视频
- (void)preload: (id<XSTPlayable>)resource
{
    if (self.playableArray.count <= 1)
        return;
    if (_nextLoadNum == 0 && _preLoadNum == 0)
        return;
    NSInteger start = [self.playableArray indexOfObject:resource];
    if (start == NSNotFound)
        return;
    [self cancelAllPreload];
    NSInteger index = 0;
    for (NSInteger i = start + 1; i < self.playableArray.count && index < _nextLoadNum; i++)
    {
        index += 1;
        id<XSTPlayable> model = self.playableArray[i];
        XSTPreLoaderModel *preModel = [self getPreloadModel: model.video_url];
        if (preModel) {
            @synchronized (self.preloadArr) {
                [self.preloadArr addObject: preModel];
            }
        }
    }
    index = 0;
    for (NSInteger i = start - 1; i >= 0 && index < _preLoadNum; i--)
    {
        index += 1;
        id<XSTPlayable> model = self.playableArray[i];
        XSTPreLoaderModel *preModel = [self getPreloadModel: model.video_url];
        if (preModel) {
            @synchronized (self.preloadArr) {
                [self.preloadArr addObject:preModel];
            }
        }
    }
    [self processLoader];
}
/// 取消所有的预加载
- (void)cancelAllPreload
{
    @synchronized (self.preloadArr) {
        if (self.preloadArr.count == 0)
        {
            return;
        }
        [self.preloadArr enumerateObjectsUsingBlock:^(XSTPreLoaderModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            [obj.loader close];
        }];
        [self.preloadArr removeAllObjects];
    }
}

- (XSTPreLoaderModel *)getPreloadModel: (NSString *)urlStr
{
    if (!urlStr)
        return nil;
    // 判断是否已在队列中
    __block Boolean res = NO;
    @synchronized (self.preloadArr) {
        [self.preloadArr enumerateObjectsUsingBlock:^(XSTPreLoaderModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([obj.url isEqualToString:urlStr])
            {
                res = YES;
                *stop = YES;
            }
        }];
    }
    if (res)
        return nil;
    NSURL *proxyUrl = [KTVHTTPCache proxyURLWithOriginalURL: [NSURL URLWithString:urlStr]];
    KTVHCDataCacheItem *item = [KTVHTTPCache cacheCacheItemWithURL:proxyUrl];
    double cachePrecent = 1.0 * item.cacheLength / item.totalLength;
    // 判断缓存已经超过10%了
    if (cachePrecent >= self.preloadPrecent)
        return nil;
    KTVHCDataRequest *req = [[KTVHCDataRequest alloc] initWithURL:proxyUrl headers:[NSDictionary dictionary]];
    KTVHCDataLoader *loader = [KTVHTTPCache cacheLoaderWithRequest:req];
    XSTPreLoaderModel *preModel = [[XSTPreLoaderModel alloc] initWithURL:urlStr loader:loader];
    return preModel;
}

- (void)processLoader
{
    @synchronized (self.preloadArr) {
        if (self.preloadArr.count == 0)
            return;
        XSTPreLoaderModel *model = self.preloadArr.firstObject;
        model.loader.delegate = self;
        [model.loader prepare];
    }
}

/// 根据loader,移除预加载任务
- (void)removePreloadTask: (KTVHCDataLoader *)loader
{
    @synchronized (self.preloadArr) {
        XSTPreLoaderModel *target = nil;
        for (XSTPreLoaderModel *model in self.preloadArr) {
            if ([model.loader isEqual:loader])
            {
                target = model;
                break;
            }
        }
        if (target)
            [self.preloadArr removeObject:target];
    }
}

// MARK: - KTVHCDataLoaderDelegate
- (void)ktv_loaderDidFinish:(KTVHCDataLoader *)loader
{
}
- (void)ktv_loader:(KTVHCDataLoader *)loader didFailWithError:(NSError *)error
{
    // 若预加载失败的话,就直接移除任务,开始下一个预加载任务
    [self removePreloadTask:loader];
    [self processLoader];
}
- (void)ktv_loader:(KTVHCDataLoader *)loader didChangeProgress:(double)progress
{
    if (progress >= self.preloadPrecent)
    {
        [loader close];
        [self removePreloadTask:loader];
        [self processLoader];
    }
}

二、无缝衔接转场动画

这里我直接拿ZFPlayerDemo中的一个列表播放,一个抖音列表播放的例子进行演示,不熟悉转场动画的,建议自行先看看唐巧的https://blog.devtang.com/2016/03/13/iOS-transition-guide/了解,这里不多说,直接上核心代码。

1、首先必须实现代理UINavigationControllerDelegate

@interface MPDetailViewController : UIViewController<UINavigationControllerDelegate>

2、传递player,startView,startImage等,并实现popback回调

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    ZFTableViewCell *cell = (ZFTableViewCell *)[tableView cellForRowAtIndexPath:indexPath];
    NSIndexPath *currentIndexPath = [self.tableView indexPathForCell:cell];
    // 点击的不是正在播放的cell,就先播放再跳转
    if ([currentIndexPath compare:self.tableView.zf_playingIndexPath] != NSOrderedSame) {
        [self.player stopCurrentPlayingCell];
        self.tableView.zf_playingIndexPath = currentIndexPath;
        [self playTheVideoAtIndexPath:currentIndexPath scrollToTop:NO];
        [self.player.currentPlayerManager.view layoutIfNeeded];
    }
    self.tableView.zf_playingIndexPath = currentIndexPath;
    
    MPDetailViewController *vc = [[MPDetailViewController alloc] init];
    vc.player = self.player;
    vc.index = indexPath.row;
    vc.startImage = cell.coverImageView.image;
    vc.startView = cell.coverImageView;
    vc.dataSource = [self.playableArray mutableCopy];
    @weakify(self)
    vc.popbackBlock = ^{
        @strongify(self)
        [self.player updateScrollViewPlayerToCell];
        [self.player.currentPlayerManager play];
    };
    self.navigationController.delegate = vc;
    [self.navigationController pushViewController:vc animated:YES];
}

3、实现UIViewControllerAnimatedTransitioning协议

/// 用于视频信息流的转场动画
@interface MPTransition : NSObject<UIViewControllerAnimatedTransitioning>

/**
 初始化动画
 
 @param duration 动画时长
 @param startView 开始视图
 @param startImage 开始图片
 @param  player 播放器
 @param operation 动画形式
 @param completion 动画完成block
 @return 动画实例
 */
+ (instancetype)animationWithDuration:(NSTimeInterval)duration
                            startView:(UIView *)startView
                           startImage:(UIImage *)startImage
                               player: (MPPlayerController *)player
                            operation:(UINavigationControllerOperation)operation
                           completion:(void(^)(void))completion;

@end

4、分别实现push,pop的转场动画

@interface MPTransition()

@property (nonatomic, strong) UIView *startView;
@property (nonatomic, strong) UIImage *startImage;
@property (nonatomic, assign) NSTimeInterval duration;
@property (nonatomic, strong) MPPlayerController *player;
@property (nonatomic, assign) UINavigationControllerOperation operation;
@property (nonatomic, assign) void(^completion)(void);
@property (nonatomic, strong) UIView *effectView;

@end

@implementation MPTransition

+ (instancetype)animationWithDuration:(NSTimeInterval)duration
                              startView:(UIView *)startView
                             startImage:(UIImage *)startImage
                                 player: (MPPlayerController *)player
                              operation:(UINavigationControllerOperation)operation
                             completion:(void(^)(void))completion
{
    MPTransition *animation = [MPTransition new];
    animation.player = player;
    animation.duration = duration;
    animation.startView = startView;
    animation.startImage = startImage;
    animation.operation = operation;
    animation.completion = completion;
    
    return animation;
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    if (self.operation == UINavigationControllerOperationPush) {
        [self startPushAnimation: transitionContext];
    }else {
        [self startPopAnimation: transitionContext];
    }
}

- (void)startPushAnimation:(id<UIViewControllerContextTransitioning>)transitionContext
{
    // 获取 fromView 和 toView
    UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
    UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    
    // 添加到动画容器视图中
    [[transitionContext containerView] addSubview:fromView];
    [[transitionContext containerView] addSubview:toView];
    
    UIImageView *bgImgView = [[UIImageView alloc] initWithFrame:fromView.bounds];
    bgImgView.contentMode = UIViewContentModeScaleAspectFill;
    bgImgView.image = self.startImage;
    UIView *colorCover = [[UIView alloc] init];
    colorCover.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.4];
    colorCover.frame = fromView.bounds;
    [bgImgView addSubview:colorCover];
    [bgImgView addSubview:self.effectView];
    [[transitionContext containerView] addSubview:bgImgView];
    
    // 创建player容器
    CGRect winFrame = CGRectZero;
    if (self.startView) {
        winFrame = [self.startView convertRect:self.startView.bounds toView:nil];
    }
    
    UIImageView *playerContainer = [[UIImageView alloc] initWithFrame:winFrame];
    playerContainer.image = self.startImage;
    playerContainer.contentMode = UIViewContentModeScaleAspectFit;
    [[transitionContext containerView]  addSubview:playerContainer];
    if (self.player) {
        self.player.currentPlayerManager.scalingMode = self.player.videoFlowScalingMode;
        self.player.currentPlayerManager.view.backgroundColor = [UIColor clearColor];
        [self.player updateNoramlPlayerWithContainerView:playerContainer];
    }
    CGFloat bottomOffset = iPhoneX ? 83 : 0;
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    CGRect targetFrame = CGRectMake(0, 0, ZFPlayer_ScreenWidth, ZFPlayer_ScreenHeight - bottomOffset);
    
    toView.alpha = 0.0f;
    bgImgView.alpha = 0;
    [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
        // mask 渐变效果
        bgImgView.alpha = 1;
        playerContainer.frame = targetFrame;
    } completion:^(BOOL finished) {
        toView.alpha = 1.0f;
        // 移除临时视图
        [bgImgView removeFromSuperview];
        [playerContainer removeFromSuperview];
        // 结束动画
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        if (self.completion) {
            self.completion();
        }
    }];
}

- (void)startPopAnimation: (id<UIViewControllerContextTransitioning>)transitionContext
{
    // 获取 fromView 和 toView
    UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
    UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    self.player.currentPlayerManager.view.backgroundColor = [UIColor blackColor];
    
    // 添加到动画容器视图中
    UIView *container = [transitionContext containerView];
    [container addSubview:toView];
    [container addSubview:fromView];
    container.backgroundColor = [UIColor clearColor];
    
    // 添加动画临时视图到 fromView
    CGFloat bottomOffset = iPhoneX ? 83 : 0;
    CGRect normalFrame = CGRectMake(0, 0, ZFPlayer_ScreenWidth, ZFPlayer_ScreenHeight - bottomOffset);
    CGRect winFrame = CGRectZero;
    if (self.startView) {
        winFrame = [self.startView convertRect:self.startView.bounds toView:nil];
    }
    
    // 显示图片
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:normalFrame];
    imageView.contentMode = UIViewContentModeScaleAspectFit;
    imageView.clipsToBounds = YES;
    imageView.image = self.startImage;
    if (self.player) {
        // pop回去的时候,设置回原来的scalingMode
        self.player.currentPlayerManager.scalingMode = ZFPlayerScalingModeAspectFill;
        [self.player updateNoramlPlayerWithContainerView:imageView];
    }
    
    [container addSubview:imageView];
    
    toView.alpha = 1;
    fromView.alpha = 1;
    NSTimeInterval duration = [self transitionDuration:transitionContext];
    
    [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
        imageView.frame = winFrame;
        fromView.alpha = 0;
    } completion:^(BOOL finished) {
        // 移除临时视图
        [imageView removeFromSuperview];
        
        // 结束动画
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        
        if (self.completion) {
            self.completion();
        }
    }];
}

- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return self.duration;
}

- (UIView *)effectView {
    if (!_effectView) {
        if (@available(iOS 8.0, *)) {
            UIBlurEffect *effect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark];
            _effectView = [[UIVisualEffectView alloc] initWithEffect:effect];
        } else {
            UIToolbar *effectView = [[UIToolbar alloc] init];
            effectView.barStyle = UIBarStyleBlackTranslucent;
            _effectView = effectView;
        }
    }
    return _effectView;
}

@end

三、相关链接

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