Initial clean commit
This commit is contained in:
28
Example/SellyCloudSDK/Play/AVLiveStreamModel.h
Normal file
28
Example/SellyCloudSDK/Play/AVLiveStreamModel.h
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// AVLiveStreamModel.h
|
||||
// AVDemo
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AVLiveStreamModel : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *vhost;
|
||||
@property (nonatomic, copy) NSString *app;
|
||||
@property (nonatomic, copy) NSString *stream;
|
||||
//如果该字段不为空,则为连麦pk房间
|
||||
@property (nonatomic, copy) NSString *stream_pk;
|
||||
@property (nonatomic, copy) NSString *url;
|
||||
@property (nonatomic, assign) NSTimeInterval startTime;
|
||||
@property (nonatomic, assign) NSTimeInterval duration;
|
||||
@property (nonatomic, copy) NSString *preview_image; // 预览图 URL
|
||||
@property (nonatomic, copy) NSString *play_protocol; // 播放协议:rtc, rtmp, flv, hls 等
|
||||
|
||||
- (NSString *)displayName;
|
||||
- (NSString *)durationString;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
29
Example/SellyCloudSDK/Play/AVLiveStreamModel.m
Normal file
29
Example/SellyCloudSDK/Play/AVLiveStreamModel.m
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// AVLiveStreamModel.m
|
||||
// AVDemo
|
||||
//
|
||||
|
||||
#import "AVLiveStreamModel.h"
|
||||
|
||||
@implementation AVLiveStreamModel
|
||||
|
||||
- (NSString *)displayName {
|
||||
return self.stream.length > 0 ? self.stream : @"未知流";
|
||||
}
|
||||
|
||||
- (NSString *)durationString {
|
||||
NSInteger totalSeconds = (NSInteger)self.duration;
|
||||
NSInteger hours = totalSeconds / 3600;
|
||||
NSInteger minutes = (totalSeconds % 3600) / 60;
|
||||
NSInteger seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
// 大于1小时,显示 hh:mm:ss
|
||||
return [NSString stringWithFormat:@"%02ld:%02ld:%02ld", (long)hours, (long)minutes, (long)seconds];
|
||||
} else {
|
||||
// 1小时内,显示 mm:ss
|
||||
return [NSString stringWithFormat:@"%02ld:%02ld", (long)minutes, (long)seconds];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
24
Example/SellyCloudSDK/Play/AVVodItemModel.h
Normal file
24
Example/SellyCloudSDK/Play/AVVodItemModel.h
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// AVVodItemModel.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AVVodItemModel : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *url;
|
||||
@property (nonatomic, copy) NSString *cover;
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) NSString *type; // mp4, hls, flv, etc.
|
||||
|
||||
+ (instancetype)modelWithUrl:(NSString *)url
|
||||
cover:(NSString *)cover
|
||||
title:(NSString *)title
|
||||
type:(NSString *)type;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
22
Example/SellyCloudSDK/Play/AVVodItemModel.m
Normal file
22
Example/SellyCloudSDK/Play/AVVodItemModel.m
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// AVVodItemModel.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
|
||||
#import "AVVodItemModel.h"
|
||||
|
||||
@implementation AVVodItemModel
|
||||
|
||||
+ (instancetype)modelWithUrl:(NSString *)url
|
||||
cover:(NSString *)cover
|
||||
title:(NSString *)title
|
||||
type:(NSString *)type {
|
||||
AVVodItemModel *model = [[AVVodItemModel alloc] init];
|
||||
model.url = url;
|
||||
model.cover = cover;
|
||||
model.title = title;
|
||||
model.type = type;
|
||||
return model;
|
||||
}
|
||||
|
||||
@end
|
||||
20
Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.h
Normal file
20
Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.h
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// SCVODPlayerViewController.h
|
||||
// SellyCloudSDK_Example
|
||||
// 直播播放器
|
||||
// Created by Caleb on 16/9/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import "AVLiveStreamModel.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SCLiveVideoPlayerViewController : UIViewController
|
||||
// 移除外部传入的属性,改为内部配置
|
||||
- (instancetype)initWithLiveStream:(AVLiveStreamModel *)stream;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
824
Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m
Normal file
824
Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m
Normal file
@@ -0,0 +1,824 @@
|
||||
//
|
||||
// SCVODPlayerViewController.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 16/9/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SCLiveVideoPlayerViewController.h"
|
||||
#import "SCPlayerConfigView.h"
|
||||
#import "AVLiveStreamModel.h"
|
||||
#import "AVConfigManager.h"
|
||||
#import "AVConstants.h"
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import <Masonry/Masonry.h>
|
||||
#import "SCLiveItemContainerView.h"
|
||||
#import <Photos/Photos.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import "TokenGenerator.h"
|
||||
#import "SCPlayerDebugView.h"
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
#import "SellyCallPiPManager.h" // 🎯 导入画中画管理器
|
||||
|
||||
@interface SCLiveVideoPlayerViewController ()<SellyLivePlayerDelegate>
|
||||
@property (nonatomic, strong) SellyLiveVideoPlayer *player;
|
||||
@property (nonatomic, strong) UIView *playerContainerView;
|
||||
@property (nonatomic, strong) SCLiveItemContainerView *containerView;
|
||||
@property (nonatomic, strong) SCPlayerConfig *currentConfig;
|
||||
@property (nonatomic, strong) UIButton *closeButton;
|
||||
@property (nonatomic, strong) SCPlayerDebugView *debugView;
|
||||
|
||||
// 🎯 连麦 PK 模式相关
|
||||
@property (nonatomic, assign) BOOL isPKMode; // 是否是连麦模式
|
||||
@property (nonatomic, strong) SellyLiveVideoPlayer *pkPlayer; // PK 播放器
|
||||
@property (nonatomic, strong) UIView *pkPlayerContainerView; // PK 播放器容器
|
||||
@property (nonatomic, strong) SCPlayerConfig *pkConfig; // PK 播放器配置
|
||||
|
||||
// 封面相关
|
||||
@property (nonatomic, strong) UIImageView *coverImageView;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
||||
@property (nonatomic, strong) UIImageView *pkCoverImageView; // PK 播放器封面
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *pkLoadingIndicator; // PK 加载指示器
|
||||
|
||||
// 保存按钮 model 的引用,用于更新状态
|
||||
@property (nonatomic, strong) SCLiveItemModel *playPauseModel;
|
||||
@property (nonatomic, strong) SCLiveItemModel *muteModel;
|
||||
@property (nonatomic, strong) SCLiveItemModel *pipModel; // 🎯 画中画按钮
|
||||
@property (nonatomic, strong) AVLiveStreamModel *streamModel;
|
||||
|
||||
// 🎯 画中画管理器
|
||||
@property (nonatomic, strong) SellyCallPiPManager *pipManager;
|
||||
@end
|
||||
|
||||
@implementation SCLiveVideoPlayerViewController
|
||||
|
||||
- (instancetype)initWithLiveStream:(AVLiveStreamModel *)stream {
|
||||
self = [super init];
|
||||
self.streamModel = stream;
|
||||
|
||||
// 🎯 检测是否是连麦 PK 模式
|
||||
self.isPKMode = (stream.stream_pk != nil && stream.stream_pk.length > 0);
|
||||
|
||||
if (self.isPKMode) {
|
||||
NSLog(@"🎯 检测到连麦 PK 房间 - 主播: %@, PK: %@", stream.stream, stream.stream_pk);
|
||||
}
|
||||
|
||||
[self convertStreamModelToPlayerConfig:stream];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = UIColor.blackColor;
|
||||
|
||||
// 隐藏导航栏
|
||||
self.navigationController.navigationBarHidden = YES;
|
||||
|
||||
// 🎯 根据是否是 PK 模式,设置不同的布局
|
||||
if (self.isPKMode) {
|
||||
[self setupPKModeLayout];
|
||||
} else {
|
||||
[self setupNormalModeLayout];
|
||||
}
|
||||
|
||||
// 添加封面图片(在播放器容器上方)
|
||||
[self setupCoverImageView];
|
||||
|
||||
// 添加关闭按钮(右上角)
|
||||
[self setupCloseButton];
|
||||
|
||||
// 添加调试视图(右上角,关闭按钮下方)
|
||||
[self setupDebugView];
|
||||
|
||||
// 🎯 添加控制按钮容器(所有模式都显示)
|
||||
[self.view addSubview:self.containerView];
|
||||
[self.containerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.offset(0);
|
||||
make.bottom.offset(-48);
|
||||
make.height.offset(100);
|
||||
}];
|
||||
|
||||
// 禁止息屏
|
||||
[UIApplication sharedApplication].idleTimerDisabled = YES;
|
||||
|
||||
[self startPlayWithConfig:self.currentConfig];
|
||||
}
|
||||
|
||||
- (void)convertStreamModelToPlayerConfig:(AVLiveStreamModel *)stream {
|
||||
if (!stream) {
|
||||
NSLog(@"❌ stream model is nil");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建主播放器配置
|
||||
SCPlayerConfig *config = [[SCPlayerConfig alloc] init];
|
||||
config.streamId = stream.stream;
|
||||
|
||||
// 根据 playProtocol 字符串设置协议类型
|
||||
NSString *protocol = stream.play_protocol.lowercaseString;
|
||||
if ([protocol isEqualToString:@"rtc"] || [protocol isEqualToString:@"webrtc"]) {
|
||||
config.protocol = SellyLiveMode_RTC;
|
||||
} else if ([protocol isEqualToString:@"rtmp"]) {
|
||||
config.protocol = SellyLiveMode_RTMP;
|
||||
} else {
|
||||
// 默认使用 RTMP(flv、hls 等也使用 RTMP 模式)
|
||||
config.protocol = SellyLiveMode_RTMP;
|
||||
}
|
||||
|
||||
NSLog(@"🔄 转换 StreamModel -> PlayerConfig: stream=%@, protocol=%@ (%@)",
|
||||
config.streamId,
|
||||
stream.play_protocol,
|
||||
config.protocol == SellyLiveMode_RTC ? @"RTC" : @"RTMP");
|
||||
|
||||
// 保存主播放器配置
|
||||
self.currentConfig = config;
|
||||
|
||||
// 🎯 如果是 PK 模式,创建 PK 播放器配置
|
||||
if (self.isPKMode) {
|
||||
SCPlayerConfig *pkConfig = [[SCPlayerConfig alloc] init];
|
||||
pkConfig.streamId = stream.stream_pk;
|
||||
pkConfig.protocol = config.protocol; // 使用相同的协议
|
||||
|
||||
NSLog(@"🔄 创建 PK PlayerConfig: stream=%@, protocol=%@",
|
||||
pkConfig.streamId,
|
||||
pkConfig.protocol == SellyLiveMode_RTC ? @"RTC" : @"RTMP");
|
||||
|
||||
self.pkConfig = pkConfig;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Layout Setup
|
||||
|
||||
// 🎯 普通模式布局(单播放器,全屏显示)
|
||||
- (void)setupNormalModeLayout {
|
||||
[self.view addSubview:self.playerContainerView];
|
||||
[self.playerContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
}
|
||||
|
||||
// 🎯 PK 模式布局(双播放器,左右并排)
|
||||
- (void)setupPKModeLayout {
|
||||
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
|
||||
CGFloat space = 8.0; // 间距
|
||||
CGFloat playerWidth = (screenWidth - space) / 2.0; // 单个播放器宽度
|
||||
CGFloat playerHeight = playerWidth * 16.0 / 9.0; // 宽高比 9:16
|
||||
|
||||
// 添加主播放器容器(左侧)
|
||||
[self.view addSubview:self.playerContainerView];
|
||||
[self.playerContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.view);
|
||||
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
|
||||
make.width.offset(playerWidth);
|
||||
make.height.offset(playerHeight);
|
||||
}];
|
||||
|
||||
// 添加 PK 播放器容器(右侧)
|
||||
[self.view addSubview:self.pkPlayerContainerView];
|
||||
[self.pkPlayerContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.view);
|
||||
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
|
||||
make.width.offset(playerWidth);
|
||||
make.height.offset(playerHeight);
|
||||
}];
|
||||
|
||||
NSLog(@"🎯 PK 模式布局完成 - 播放器宽度: %.1f, 高度: %.1f, 间距: %.1f", playerWidth, playerHeight, space);
|
||||
}
|
||||
|
||||
- (void)setupCloseButton {
|
||||
_closeButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_closeButton setImage:[UIImage systemImageNamed:@"xmark.circle.fill"] forState:UIControlStateNormal];
|
||||
_closeButton.tintColor = [UIColor whiteColor];
|
||||
[_closeButton addTarget:self action:@selector(closeButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:_closeButton];
|
||||
[_closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.offset(-16);
|
||||
make.top.offset(50);
|
||||
make.width.height.offset(44);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)setupDebugView {
|
||||
_debugView = [[SCPlayerDebugView alloc] init];
|
||||
[self.view addSubview:_debugView];
|
||||
[_debugView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.offset(-16);
|
||||
make.top.equalTo(self.closeButton.mas_bottom).offset(16);
|
||||
make.width.offset(300);
|
||||
}];
|
||||
|
||||
// 添加初始日志
|
||||
[_debugView appendLog:@"调试器已初始化" withPrefix:@"✅"];
|
||||
}
|
||||
|
||||
- (void)setupCoverImageView {
|
||||
// 创建主播放器封面图片视图
|
||||
_coverImageView = [[UIImageView alloc] init];
|
||||
_coverImageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_coverImageView.backgroundColor = [UIColor blackColor];
|
||||
[self.view addSubview:_coverImageView];
|
||||
|
||||
if (self.isPKMode) {
|
||||
// 🎯 PK 模式:封面覆盖左侧播放器
|
||||
[_coverImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.playerContainerView);
|
||||
}];
|
||||
} else {
|
||||
// 普通模式:封面全屏
|
||||
[_coverImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
}
|
||||
|
||||
// 创建主播放器加载指示器
|
||||
_loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
|
||||
_loadingIndicator.color = [UIColor whiteColor];
|
||||
[self.view addSubview:_loadingIndicator];
|
||||
[_loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.coverImageView);
|
||||
}];
|
||||
[_loadingIndicator startAnimating];
|
||||
|
||||
// 🎯 如果是 PK 模式,创建 PK 播放器的封面和加载指示器
|
||||
if (self.isPKMode) {
|
||||
_pkCoverImageView = [[UIImageView alloc] init];
|
||||
_pkCoverImageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_pkCoverImageView.backgroundColor = [UIColor blackColor];
|
||||
[self.view addSubview:_pkCoverImageView];
|
||||
[_pkCoverImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.pkPlayerContainerView);
|
||||
}];
|
||||
|
||||
_pkLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
|
||||
_pkLoadingIndicator.color = [UIColor whiteColor];
|
||||
[self.view addSubview:_pkLoadingIndicator];
|
||||
[_pkLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.pkCoverImageView);
|
||||
}];
|
||||
[_pkLoadingIndicator startAnimating];
|
||||
}
|
||||
|
||||
// 加载主播放器封面图片
|
||||
if (self.streamModel.preview_image && self.streamModel.preview_image.length > 0) {
|
||||
[self loadCoverImage:self.streamModel.preview_image];
|
||||
[self.debugView appendLog:@"正在加载主播放器封面..." withPrefix:@"🖼️"];
|
||||
} else {
|
||||
[self.debugView appendLog:@"主播放器无封面,等待视频首帧..." withPrefix:@"⏳"];
|
||||
}
|
||||
|
||||
// 🎯 PK 模式下,PK 播放器也显示相同的封面(因为没有单独的 PK 封面数据)
|
||||
if (self.isPKMode) {
|
||||
if (self.streamModel.preview_image && self.streamModel.preview_image.length > 0) {
|
||||
[self loadPKCoverImage:self.streamModel.preview_image];
|
||||
[self.debugView appendLog:@"正在加载 PK 播放器封面..." withPrefix:@"🖼️"];
|
||||
} else {
|
||||
[self.debugView appendLog:@"PK 播放器无封面,等待视频首帧..." withPrefix:@"⏳"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)loadCoverImage:(NSString *)imageUrlString {
|
||||
NSURL *url = [NSURL URLWithString:imageUrlString];
|
||||
if (!url) {
|
||||
NSLog(@"❌ 无效的封面图片 URL: %@", imageUrlString);
|
||||
[self.debugView appendLog:@"无效的封面 URL" withPrefix:@"❌"];
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 SDWebImage 加载图片,支持缓存和占位符
|
||||
[self.coverImageView sd_setImageWithURL:[NSURL URLWithString:imageUrlString]];
|
||||
}
|
||||
|
||||
// 🎯 加载 PK 播放器封面
|
||||
- (void)loadPKCoverImage:(NSString *)imageUrlString {
|
||||
NSURL *url = [NSURL URLWithString:imageUrlString];
|
||||
if (!url) {
|
||||
NSLog(@"❌ 无效的 PK 封面图片 URL: %@", imageUrlString);
|
||||
[self.debugView appendLog:@"无效的 PK 封面 URL" withPrefix:@"❌"];
|
||||
return;
|
||||
}
|
||||
|
||||
[self.pkCoverImageView sd_setImageWithURL:[NSURL URLWithString:imageUrlString]];
|
||||
}
|
||||
|
||||
- (void)hideCoverImage {
|
||||
[UIView animateWithDuration:0.3 animations:^{
|
||||
self.coverImageView.alpha = 0;
|
||||
self.loadingIndicator.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self.coverImageView removeFromSuperview];
|
||||
self.coverImageView = nil;
|
||||
[self.loadingIndicator stopAnimating];
|
||||
[self.loadingIndicator removeFromSuperview];
|
||||
self.loadingIndicator = nil;
|
||||
}];
|
||||
}
|
||||
|
||||
// 🎯 隐藏 PK 播放器封面
|
||||
- (void)hidePKCoverImage {
|
||||
[UIView animateWithDuration:0.3 animations:^{
|
||||
self.pkCoverImageView.alpha = 0;
|
||||
self.pkLoadingIndicator.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self.pkCoverImageView removeFromSuperview];
|
||||
self.pkCoverImageView = nil;
|
||||
[self.pkLoadingIndicator stopAnimating];
|
||||
[self.pkLoadingIndicator removeFromSuperview];
|
||||
self.pkLoadingIndicator = nil;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)closeButtonTapped {
|
||||
// 停止播放
|
||||
if (self.player) {
|
||||
[self.player stop];
|
||||
}
|
||||
// 🎯 PK 模式:停止 PK 播放器
|
||||
if (self.isPKMode && self.pkPlayer) {
|
||||
[self.pkPlayer stop];
|
||||
}
|
||||
|
||||
// 恢复导航栏
|
||||
self.navigationController.navigationBarHidden = NO;
|
||||
|
||||
// 返回上一页
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
// 确保封面在播放器上方,但在控制按钮、调试视图和关闭按钮下方
|
||||
if (self.coverImageView) {
|
||||
[self.view bringSubviewToFront:self.coverImageView];
|
||||
[self.view bringSubviewToFront:self.loadingIndicator];
|
||||
}
|
||||
// 🎯 PK 模式:确保 PK 封面也在正确的层级
|
||||
if (self.isPKMode && self.pkCoverImageView) {
|
||||
[self.view bringSubviewToFront:self.pkCoverImageView];
|
||||
[self.view bringSubviewToFront:self.pkLoadingIndicator];
|
||||
}
|
||||
[self.view bringSubviewToFront:self.containerView];
|
||||
[self.view bringSubviewToFront:self.debugView];
|
||||
[self.view bringSubviewToFront:self.closeButton];
|
||||
}
|
||||
|
||||
- (void)showConfigView {
|
||||
SCPlayerConfigView *configView = [[SCPlayerConfigView alloc] init];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[configView showInViewController:self callback:^(SCPlayerConfig *config) {
|
||||
weakSelf.currentConfig = config;
|
||||
[weakSelf startPlayWithConfig:config];
|
||||
}];
|
||||
|
||||
// 配置视图现在添加到 self.view 上,确保关闭按钮在最上层
|
||||
[self.view bringSubviewToFront:self.closeButton];
|
||||
}
|
||||
|
||||
- (void)startPlayWithConfig:(SCPlayerConfig *)config {
|
||||
// 应用本地服务器设置
|
||||
AVConfigManager *avConfig = AVConfigManager.sharedManager;
|
||||
if (avConfig.localServerEnabled) {
|
||||
[SellyCloudManager.sharedInstance setLocalServerIP:avConfig.localServerIP];
|
||||
[SellyCloudManager.sharedInstance setForcedNodeIP:nil];
|
||||
} else {
|
||||
[SellyCloudManager.sharedInstance setLocalServerIP:nil];
|
||||
[SellyCloudManager.sharedInstance setForcedNodeIP:@"43.106.88.179:1080"];
|
||||
}
|
||||
|
||||
// 如果已经有播放器在运行,先停止
|
||||
if (self.player) {
|
||||
[self.player stop];
|
||||
[self.player setRenderView:nil];
|
||||
self.player = nil;
|
||||
}
|
||||
self.currentConfig = config;
|
||||
|
||||
// 🎯 创建主播放器
|
||||
NSString *token = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:config.streamId type:@"pull" key:APP_SECRET];
|
||||
SellyLiveVideoPlayer *player = [[SellyLiveVideoPlayer alloc] init];
|
||||
player.token = token;
|
||||
player.delegate = self;
|
||||
player.scaleMode = SellyPlayerScalingModeAspectFit;
|
||||
[player setRenderView:self.playerContainerView];
|
||||
|
||||
// 确保初始音量为正常状态(未静音)
|
||||
if (player.playbackVolume == 0) {
|
||||
player.playbackVolume = 1.0;
|
||||
}
|
||||
self.player = player;
|
||||
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"初始化主播放器: streamId=%@", config.streamId] withPrefix:@"🎬"];
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"协议: %@", config.protocol == SellyLiveMode_RTC ? @"RTC" : @"RTMP"] withPrefix:@"📡"];
|
||||
|
||||
// 🎯 如果是 PK 模式,创建 PK 播放器
|
||||
if (self.isPKMode && self.pkConfig) {
|
||||
NSString *pkToken = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:self.pkConfig.streamId type:@"pull" key:APP_SECRET];
|
||||
SellyLiveVideoPlayer *pkPlayer = [[SellyLiveVideoPlayer alloc] init];
|
||||
pkPlayer.token = pkToken;
|
||||
pkPlayer.delegate = self;
|
||||
pkPlayer.scaleMode = SellyPlayerScalingModeAspectFit;
|
||||
[pkPlayer setRenderView:self.pkPlayerContainerView];
|
||||
|
||||
if (pkPlayer.playbackVolume == 0) {
|
||||
pkPlayer.playbackVolume = 1.0;
|
||||
}
|
||||
self.pkPlayer = pkPlayer;
|
||||
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"初始化 PK 播放器: streamId=%@", self.pkConfig.streamId] withPrefix:@"🎬"];
|
||||
}
|
||||
|
||||
[self startPlay];
|
||||
|
||||
// 设置控制按钮
|
||||
[self setupControlButtons];
|
||||
}
|
||||
|
||||
- (void)startPlay {
|
||||
// 判断是 URL 还是 streamId
|
||||
SCPlayerConfig *config = self.currentConfig;
|
||||
if ([config.streamId hasPrefix:@"rtmp://"] ||
|
||||
[config.streamId hasPrefix:@"http://"] ||
|
||||
[config.streamId hasPrefix:@"https://"]) {
|
||||
// 完整 URL
|
||||
[self.player startPlayUrl:config.streamId];
|
||||
}
|
||||
else if (self.streamModel.url) {
|
||||
[self.player startPlayUrl:self.streamModel.url];
|
||||
}
|
||||
else {
|
||||
// StreamId,需要构造 SellyPlayerStreamInfo
|
||||
SellyPlayerStreamInfo *streamInfo = [[SellyPlayerStreamInfo alloc] init];
|
||||
streamInfo.streamId = config.streamId;
|
||||
streamInfo.protocol = config.protocol;
|
||||
[self.player startPlayStreamInfo:streamInfo];
|
||||
}
|
||||
|
||||
// 🎯 如果是 PK 模式,启动 PK 播放器
|
||||
if (self.isPKMode && self.pkPlayer && self.pkConfig) {
|
||||
SellyPlayerStreamInfo *pkStreamInfo = [[SellyPlayerStreamInfo alloc] init];
|
||||
pkStreamInfo.streamId = self.pkConfig.streamId;
|
||||
pkStreamInfo.protocol = self.pkConfig.protocol;
|
||||
[self.pkPlayer startPlayStreamInfo:pkStreamInfo];
|
||||
|
||||
[self.debugView appendLog:@"PK 播放器已启动" withPrefix:@"▶️"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setupControlButtons {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
NSMutableArray *items = NSMutableArray.new;
|
||||
|
||||
// 播放/暂停按钮(所有模式都显示)
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypePlayPause;
|
||||
model.title = @"播放/暂停";
|
||||
model.isSelected = NO;
|
||||
model.clickCallback = ^{
|
||||
if (weakSelf.player.isPlaying) {
|
||||
[weakSelf pause];
|
||||
} else {
|
||||
[weakSelf start];
|
||||
}
|
||||
[weakSelf updatePlayPauseButtonState];
|
||||
};
|
||||
self.playPauseModel = model;
|
||||
[items addObject:model];
|
||||
}
|
||||
|
||||
// 静音按钮(所有模式都显示)
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypeVolume;
|
||||
model.title = @"静音";
|
||||
|
||||
// 获取当前音量状态
|
||||
CGFloat currentVolume = self.player.playbackVolume;
|
||||
model.isSelected = (currentVolume == 0);
|
||||
|
||||
NSLog(@"📢 静音按钮初始化 - 当前音量: %.2f, isSelected: %d", currentVolume, model.isSelected);
|
||||
|
||||
model.clickCallback = ^{
|
||||
// 🎯 PK 模式:同时控制所有播放器的音量
|
||||
if (weakSelf.isPKMode) {
|
||||
BOOL shouldMute = (weakSelf.player.playbackVolume > 0);
|
||||
weakSelf.player.playbackVolume = shouldMute ? 0 : 1.0;
|
||||
weakSelf.pkPlayer.playbackVolume = shouldMute ? 0 : 1.0;
|
||||
NSLog(@"📢 PK 模式 - 所有播放器音量: %.2f", weakSelf.player.playbackVolume);
|
||||
} else {
|
||||
weakSelf.player.playbackVolume = 1 - weakSelf.player.playbackVolume;
|
||||
NSLog(@"📢 点击静音按钮 - 新音量: %.2f", weakSelf.player.playbackVolume);
|
||||
}
|
||||
[weakSelf updateMuteButtonState];
|
||||
};
|
||||
self.muteModel = model;
|
||||
[items addObject:model];
|
||||
}
|
||||
|
||||
// 🎯 以下按钮仅在非 PK 模式下显示
|
||||
if (!self.isPKMode) {
|
||||
// 截图按钮
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypeScreenshot;
|
||||
model.title = @"截图";
|
||||
model.clickCallback = ^{
|
||||
[weakSelf saveCurrentFrameToPhotoAlbum:weakSelf.player.getCurrentImage];
|
||||
};
|
||||
[items addObject:model];
|
||||
}
|
||||
|
||||
// 画中画按钮
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypePiP;
|
||||
model.title = @"画中画";
|
||||
model.isSelected = NO;
|
||||
model.clickCallback = ^{
|
||||
[weakSelf togglePiP];
|
||||
};
|
||||
self.pipModel = model;
|
||||
[items addObject:model];
|
||||
}
|
||||
}
|
||||
|
||||
self.containerView.models = items;
|
||||
[self updatePlayPauseButtonState];
|
||||
}
|
||||
|
||||
- (void)updatePlayPauseButtonState {
|
||||
self.playPauseModel.isSelected = self.player.isPlaying;
|
||||
// 刷新 UI
|
||||
self.containerView.models = self.containerView.models;
|
||||
}
|
||||
|
||||
- (void)updateMuteButtonState {
|
||||
self.muteModel.isSelected = (self.player.playbackVolume == 0);
|
||||
// 刷新 UI
|
||||
self.containerView.models = self.containerView.models;
|
||||
}
|
||||
|
||||
// 🎯 更新画中画按钮状态
|
||||
- (void)updatePiPButtonState {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
self.pipModel.isSelected = self.pipManager.pipActive;
|
||||
} else {
|
||||
self.pipModel.isSelected = NO;
|
||||
}
|
||||
// 刷新 UI
|
||||
self.containerView.models = self.containerView.models;
|
||||
}
|
||||
|
||||
// 🎯 切换画中画
|
||||
- (void)togglePiP {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (!self.pipManager) {
|
||||
// 初始化画中画管理器
|
||||
self.pipManager = [[SellyCallPiPManager alloc] initWithRenderView:self.playerContainerView];
|
||||
[self.pipManager setupIfNeeded];
|
||||
[self.debugView appendLog:@"画中画初始化完成" withPrefix:@"🖼️"];
|
||||
}
|
||||
|
||||
if (self.pipManager.pipPossible) {
|
||||
[self.pipManager togglePiP];
|
||||
[self.debugView appendLog:self.pipManager.pipActive ? @"开启画中画" : @"关闭画中画" withPrefix:@"🖼️"];
|
||||
// 延迟更新按钮状态,等待 PiP 状态变化
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self updatePiPButtonState];
|
||||
});
|
||||
} else {
|
||||
[self.debugView appendLog:@"当前设备不支持画中画" withPrefix:@"❌"];
|
||||
NSLog(@"当前设备不支持画中画");
|
||||
}
|
||||
} else {
|
||||
[self.debugView appendLog:@"iOS 15 以上才支持自定义画中画" withPrefix:@"❌"];
|
||||
NSLog(@"iOS 15 以上才支持自定义画中画");
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Player Control
|
||||
|
||||
- (NSString *)stringFromPlayerState:(SellyPlayerState)state {
|
||||
switch (state) {
|
||||
case SellyPlayerStateIdle:
|
||||
return @"空闲 (Idle)";
|
||||
case SellyPlayerStateConnecting:
|
||||
return @"连接中 (Connecting)";
|
||||
case SellyPlayerStatePlaying:
|
||||
return @"播放中 (Playing)";
|
||||
case SellyPlayerStatePaused:
|
||||
return @"暂停 (Paused)";
|
||||
case SellyPlayerStateStoppedOrEnded:
|
||||
return @"已停止/结束 (Stopped/Ended)";
|
||||
case SellyPlayerStateFailed:
|
||||
return @"失败 (Failed)";
|
||||
default:
|
||||
return [NSString stringWithFormat:@"未知状态 (%ld)", (long)state];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)start {
|
||||
[self startPlay];
|
||||
}
|
||||
|
||||
- (void)pause {
|
||||
[self.player stop];
|
||||
// 🎯 PK 模式:同时停止 PK 播放器
|
||||
if (self.isPKMode && self.pkPlayer) {
|
||||
[self.pkPlayer stop];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - SellyPlayerManagerDelegate
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player playbackDidFinished:(NSDictionary *)resultInfo {
|
||||
NSLog(@"Playback finished: %@", resultInfo);
|
||||
[self.debugView appendLog:@"播放已结束" withPrefix:@"⏹️"];
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player playbackStateChanged:(SellyPlayerState)state {
|
||||
NSString *stateString = [self stringFromPlayerState:state];
|
||||
NSLog(@"🎬 播放状态变更: %@ (rawValue: %ld)", stateString, (long)state);
|
||||
|
||||
// 添加到调试器
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"状态变更: %@", stateString] withPrefix:@"🎬"];
|
||||
|
||||
// 🎯 当播放状态为 Playing 时,初始化画中画管理器
|
||||
if (state == SellyPlayerStatePlaying && !self.pipManager) {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
self.pipManager = [[SellyCallPiPManager alloc] initWithRenderView:self.playerContainerView];
|
||||
[self.pipManager setupIfNeeded];
|
||||
[self.debugView appendLog:@"画中画已就绪" withPrefix:@"✅"];
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放/暂停按钮状态
|
||||
[self updatePlayPauseButtonState];
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player onError:(NSError *)error {
|
||||
NSLog(@"Player error: %@", error.localizedDescription);
|
||||
|
||||
// 添加到调试器
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"错误: %@", error.localizedDescription] withPrefix:@"🔴"];
|
||||
|
||||
// 显示错误提示
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"播放错误"
|
||||
message:error.localizedDescription
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"重试" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
|
||||
// 如果有 streamModel 就重新播放,否则显示配置界面
|
||||
if (self.currentConfig) {
|
||||
[self startPlayWithConfig:self.currentConfig];
|
||||
} else {
|
||||
[self showConfigView];
|
||||
}
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
|
||||
[self closeButtonTapped];
|
||||
}]];
|
||||
[self presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player firstRemoteVideoFrame:(NSInteger)elapse {
|
||||
// 🎯 区分主播放器和 PK 播放器
|
||||
if (player == self.player) {
|
||||
NSLog(@"###主播放器视频首帧加载耗时 == %ldms", elapse);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"主播放器视频首帧: %ldms", elapse] withPrefix:@"📹"];
|
||||
[self hideCoverImage];
|
||||
} else if (self.isPKMode && player == self.pkPlayer) {
|
||||
NSLog(@"###PK 播放器视频首帧加载耗时 == %ldms", elapse);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"PK 播放器视频首帧: %ldms", elapse] withPrefix:@"📹"];
|
||||
[self hidePKCoverImage];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player firstRemoteAudioFrame:(NSInteger)elapse {
|
||||
NSLog(@"###语音首帧加载耗时 == %ldms",elapse);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"音频首帧: %ldms", elapse] withPrefix:@"🔊"];
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player onFrameCatchingStart:(CGFloat)rate {
|
||||
NSLog(@"###追帧开始");
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"追帧开始 x%.1f倍",rate] withPrefix:@"⚡️"];
|
||||
}
|
||||
|
||||
- (void)playerDidEndFrameCatching:(SellyLiveVideoPlayer *)player {
|
||||
NSLog(@"###追帧结束");
|
||||
[self.debugView appendLog:@"追帧结束" withPrefix:@"✅"];
|
||||
}
|
||||
|
||||
//自定义渲染,实现画中画
|
||||
- (BOOL)player:(SellyLiveVideoPlayer *)player onRenderVideoFrame:(SellyRTCVideoFrame *)videoFrame {
|
||||
if (self.isPKMode) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
// 🎯 将视频帧喂给画中画管理器
|
||||
if (self.pipManager && videoFrame.pixelBuffer) {
|
||||
[self.pipManager feedVideoFrame:videoFrame];
|
||||
}
|
||||
// 返回 true 表示 SDK 继续默认渲染到播放器视图
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)player:(SellyLiveVideoPlayer *)player onDebugInfo:(SellyLivePlayerStats *)stats {
|
||||
NSLog(@"统计信息: 协议=%@, CPU=%ld%%/%ld%%, FPS=%ld, 码率=%ldkbps/%ldkbps, RTT=%ldms ,freezeTime=%ldms, 分辨率=%@, 丢包率=%.2f%% timestampMs=%lld",
|
||||
stats.protocol,
|
||||
stats.appCpu,
|
||||
stats.systemCpu,
|
||||
stats.fps,
|
||||
stats.videoBitrate,
|
||||
stats.audioBitrate,
|
||||
stats.rtt,
|
||||
stats.freezeTime,
|
||||
NSStringFromCGSize(stats.videoSize),
|
||||
stats.packetLossRate,
|
||||
stats.timestampMs);
|
||||
}
|
||||
|
||||
#pragma mark - Screenshot
|
||||
|
||||
- (void)saveCurrentFrameToPhotoAlbum:(UIImage *)image {
|
||||
if (!image) {
|
||||
NSLog(@"当前没有图像可保存");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
||||
if (status == PHAuthorizationStatusDenied || status == PHAuthorizationStatusRestricted) {
|
||||
NSLog(@"无权限访问相册,请到设置中开启权限");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == PHAuthorizationStatusNotDetermined) {
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus newStatus) {
|
||||
if (newStatus == PHAuthorizationStatusAuthorized) {
|
||||
[self saveImage:image];
|
||||
}
|
||||
}];
|
||||
} else {
|
||||
[self saveImage:image];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)saveImage:(UIImage *)image {
|
||||
UIImageWriteToSavedPhotosAlbum(image, self, @selector(image:didFinishSavingWithError:contextInfo:), NULL);
|
||||
}
|
||||
|
||||
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo {
|
||||
if (error) {
|
||||
NSLog(@"保存失败:%@", error.localizedDescription);
|
||||
} else {
|
||||
NSLog(@"已保存当前视频帧至相册");
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Loading
|
||||
|
||||
- (UIView *)playerContainerView {
|
||||
if (!_playerContainerView) {
|
||||
_playerContainerView = [[UIView alloc] init];
|
||||
_playerContainerView.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
return _playerContainerView;
|
||||
}
|
||||
|
||||
// 🎯 PK 播放器容器
|
||||
- (UIView *)pkPlayerContainerView {
|
||||
if (!_pkPlayerContainerView) {
|
||||
_pkPlayerContainerView = [[UIView alloc] init];
|
||||
_pkPlayerContainerView.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
return _pkPlayerContainerView;
|
||||
}
|
||||
|
||||
- (SCLiveItemContainerView *)containerView {
|
||||
if (!_containerView) {
|
||||
_containerView = [[SCLiveItemContainerView alloc] init];
|
||||
}
|
||||
return _containerView;
|
||||
}
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (void)dealloc {
|
||||
[UIApplication sharedApplication].idleTimerDisabled = NO;
|
||||
NSLog(@"###%@ dealloc", NSStringFromClass(self.class));
|
||||
}
|
||||
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
[super viewDidDisappear:animated];
|
||||
|
||||
// 恢复导航栏
|
||||
self.navigationController.navigationBarHidden = NO;
|
||||
|
||||
// 退出页面时彻底销毁 PiP,释放旧 controller,下次进入时重新创建
|
||||
[self.pipManager invalidate];
|
||||
self.pipManager = nil;
|
||||
}
|
||||
|
||||
@end
|
||||
33
Example/SellyCloudSDK/Play/SCPlayerConfigView.h
Normal file
33
Example/SellyCloudSDK/Play/SCPlayerConfigView.h
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// SCPlayerConfigView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 16/12/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SCPlayerConfig : NSObject
|
||||
@property (nonatomic, assign) SellyLiveMode protocol;
|
||||
@property (nonatomic, strong) NSString *streamId;
|
||||
|
||||
/// 保存配置到 UserDefaults
|
||||
- (void)saveToUserDefaults;
|
||||
|
||||
/// 从 UserDefaults 加载配置
|
||||
+ (instancetype)loadFromUserDefaults;
|
||||
|
||||
@end
|
||||
|
||||
@interface SCPlayerConfigView : UIView
|
||||
|
||||
- (void)showInViewController:(UIViewController *)viewController
|
||||
callback:(void(^)(SCPlayerConfig *config))callback;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
411
Example/SellyCloudSDK/Play/SCPlayerConfigView.m
Normal file
411
Example/SellyCloudSDK/Play/SCPlayerConfigView.m
Normal file
@@ -0,0 +1,411 @@
|
||||
//
|
||||
// SCPlayerConfigView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 16/12/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SCPlayerConfigView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import "AVConfigManager.h"
|
||||
|
||||
// 配置保存的 key
|
||||
static NSString * const kSCPlayerConfigProtocol = @"SCPlayerConfigProtocol";
|
||||
static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
|
||||
|
||||
@implementation SCPlayerConfig
|
||||
|
||||
- (void)saveToUserDefaults {
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
[defaults setInteger:self.protocol forKey:kSCPlayerConfigProtocol];
|
||||
if (self.streamId) {
|
||||
[defaults setObject:self.streamId forKey:kSCPlayerConfigStreamId];
|
||||
}
|
||||
[defaults synchronize];
|
||||
}
|
||||
|
||||
+ (instancetype)loadFromUserDefaults {
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
|
||||
SCPlayerConfig *config = [[SCPlayerConfig alloc] init];
|
||||
|
||||
// 加载协议,默认为 RTMP
|
||||
if ([defaults objectForKey:kSCPlayerConfigProtocol]) {
|
||||
config.protocol = [defaults integerForKey:kSCPlayerConfigProtocol];
|
||||
} else {
|
||||
config.protocol = SellyLiveMode_RTMP;
|
||||
}
|
||||
|
||||
// 加载 streamId
|
||||
NSString *streamId = [defaults objectForKey:kSCPlayerConfigStreamId];
|
||||
config.streamId = streamId ? streamId : @"test";
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface SCPlayerConfigView () <UITextFieldDelegate>
|
||||
@property (nonatomic, strong) UIView *contentView;
|
||||
@property (nonatomic, strong) UIVisualEffectView *backgroundView;
|
||||
@property (nonatomic, strong) UISegmentedControl *protocolSegment;
|
||||
@property (nonatomic, strong) UITextField *streamIdField;
|
||||
@property (nonatomic, strong) UIButton *playButton;
|
||||
@property (nonatomic, copy) void(^callback)(SCPlayerConfig *config);
|
||||
@property (nonatomic, strong) MASConstraint *backgroundViewCenterYConstraint;
|
||||
@property (nonatomic, strong) UISwitch *localServerSwitch;
|
||||
@end
|
||||
|
||||
@implementation SCPlayerConfigView
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self setupView];
|
||||
[self loadSavedConfig];
|
||||
[self registerKeyboardNotifications];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)setupView {
|
||||
self.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.5];
|
||||
|
||||
// 添加点击手势 - 点击背景关闭
|
||||
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(backgroundTapped:)];
|
||||
tapGesture.cancelsTouchesInView = NO; // 不阻止子视图的点击事件
|
||||
[self addGestureRecognizer:tapGesture];
|
||||
|
||||
// 背景模糊效果
|
||||
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemMaterial];
|
||||
_backgroundView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
|
||||
_backgroundView.layer.cornerRadius = 16;
|
||||
_backgroundView.layer.masksToBounds = YES;
|
||||
_backgroundView.userInteractionEnabled = YES; // 确保内容区域可以响应事件
|
||||
[self addSubview:_backgroundView];
|
||||
|
||||
[_backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// 不完全居中,稍微向下偏移,避开顶部的关闭按钮
|
||||
make.centerX.equalTo(self);
|
||||
// 保存 centerY 约束的引用,以便在键盘出现时调整
|
||||
self.backgroundViewCenterYConstraint = make.centerY.equalTo(self).offset(30); // 向下偏移30pt
|
||||
make.left.offset(40);
|
||||
make.right.offset(-40);
|
||||
}];
|
||||
|
||||
// 内容容器
|
||||
_contentView = [[UIView alloc] init];
|
||||
[_backgroundView.contentView addSubview:_contentView];
|
||||
[_contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_backgroundView.contentView).insets(UIEdgeInsetsMake(24, 24, 24, 24));
|
||||
}];
|
||||
|
||||
// 标题
|
||||
UILabel *titleLabel = [[UILabel alloc] init];
|
||||
titleLabel.text = @"播放配置";
|
||||
titleLabel.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold];
|
||||
titleLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[_contentView addSubview:titleLabel];
|
||||
[titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(_contentView);
|
||||
make.left.right.equalTo(_contentView);
|
||||
make.height.offset(30);
|
||||
}];
|
||||
|
||||
// 协议选择标签
|
||||
UILabel *protocolLabel = [[UILabel alloc] init];
|
||||
protocolLabel.text = @"播放协议";
|
||||
protocolLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
[_contentView addSubview:protocolLabel];
|
||||
[protocolLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(titleLabel.mas_bottom).offset(24);
|
||||
make.left.equalTo(_contentView);
|
||||
make.height.offset(22);
|
||||
}];
|
||||
|
||||
// 协议选择器
|
||||
_protocolSegment = [[UISegmentedControl alloc] initWithItems:@[@"RTMP", @"RTC"]];
|
||||
_protocolSegment.selectedSegmentIndex = 0;
|
||||
[_contentView addSubview:_protocolSegment];
|
||||
[_protocolSegment mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(protocolLabel.mas_bottom).offset(8);
|
||||
make.left.right.equalTo(_contentView);
|
||||
make.height.offset(36);
|
||||
}];
|
||||
|
||||
// Stream ID 标签
|
||||
UILabel *streamIdLabel = [[UILabel alloc] init];
|
||||
streamIdLabel.text = @"Stream ID / URL。请输入 Stream ID 或完整 URL";
|
||||
streamIdLabel.font = [UIFont systemFontOfSize:14];
|
||||
streamIdLabel.numberOfLines = 0;
|
||||
[_contentView addSubview:streamIdLabel];
|
||||
[streamIdLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(_protocolSegment.mas_bottom).offset(24);
|
||||
make.left.equalTo(_contentView);
|
||||
make.right.equalTo(_contentView);
|
||||
make.height.offset(34);
|
||||
}];
|
||||
|
||||
// Stream ID 输入框
|
||||
_streamIdField = [[UITextField alloc] init];
|
||||
_streamIdField.placeholder = @"请输入 Stream ID 或完整 URL";
|
||||
_streamIdField.borderStyle = UITextBorderStyleRoundedRect;
|
||||
_streamIdField.font = [UIFont systemFontOfSize:15];
|
||||
_streamIdField.clearButtonMode = UITextFieldViewModeWhileEditing;
|
||||
_streamIdField.returnKeyType = UIReturnKeyDone;
|
||||
_streamIdField.delegate = self;
|
||||
|
||||
// 禁用首字母自动大写
|
||||
_streamIdField.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
// 禁用自动更正
|
||||
_streamIdField.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
// 设置键盘类型为 URL 类型(更适合输入 URL)
|
||||
_streamIdField.keyboardType = UIKeyboardTypeURL;
|
||||
|
||||
[_contentView addSubview:_streamIdField];
|
||||
[_streamIdField mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(streamIdLabel.mas_bottom).offset(8);
|
||||
make.left.right.equalTo(_contentView);
|
||||
make.height.offset(44);
|
||||
}];
|
||||
|
||||
// 本地服务器开关行
|
||||
UILabel *localServerLabel = [[UILabel alloc] init];
|
||||
localServerLabel.text = @"本地服务器";
|
||||
localServerLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
[_contentView addSubview:localServerLabel];
|
||||
[localServerLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(_streamIdField.mas_bottom).offset(20);
|
||||
make.left.equalTo(_contentView);
|
||||
make.height.offset(31);
|
||||
}];
|
||||
|
||||
_localServerSwitch = [[UISwitch alloc] init];
|
||||
[_contentView addSubview:_localServerSwitch];
|
||||
[_localServerSwitch mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(localServerLabel);
|
||||
make.right.equalTo(_contentView);
|
||||
}];
|
||||
|
||||
// 播放按钮
|
||||
_playButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_playButton setTitle:@"开始播放" forState:UIControlStateNormal];
|
||||
_playButton.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightBold];
|
||||
_playButton.backgroundColor = [UIColor systemBlueColor];
|
||||
[_playButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
_playButton.layer.cornerRadius = 12;
|
||||
_playButton.layer.masksToBounds = YES;
|
||||
[_playButton addTarget:self action:@selector(playButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[_contentView addSubview:_playButton];
|
||||
[_playButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(localServerLabel.mas_bottom).offset(20);
|
||||
make.left.right.equalTo(_contentView);
|
||||
make.height.offset(50);
|
||||
make.bottom.equalTo(_contentView);
|
||||
}];
|
||||
|
||||
// 移除点击背景关闭的手势,只能通过播放按钮关闭
|
||||
}
|
||||
|
||||
- (void)playButtonTapped {
|
||||
NSString *streamId = [_streamIdField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (streamId.length == 0) {
|
||||
// 显示提示
|
||||
[self showAlertWithMessage:@"请输入 Stream ID 或 URL"];
|
||||
return;
|
||||
}
|
||||
|
||||
SCPlayerConfig *config = [[SCPlayerConfig alloc] init];
|
||||
config.protocol = _protocolSegment.selectedSegmentIndex == 0 ? SellyLiveMode_RTMP : SellyLiveMode_RTC;
|
||||
config.streamId = streamId;
|
||||
|
||||
// 保存配置
|
||||
[config saveToUserDefaults];
|
||||
|
||||
// 保存本地服务器开关状态
|
||||
AVConfigManager.sharedManager.localServerEnabled = _localServerSwitch.on;
|
||||
[AVConfigManager.sharedManager saveConfig];
|
||||
|
||||
if (self.callback) {
|
||||
self.callback(config);
|
||||
}
|
||||
|
||||
[self dismiss];
|
||||
}
|
||||
|
||||
- (void)showAlertWithMessage:(NSString *)message {
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示"
|
||||
message:message
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
|
||||
|
||||
// 获取当前的 viewController
|
||||
UIViewController *topVC = [self topViewController];
|
||||
if (topVC) {
|
||||
[topVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIViewController *)topViewController {
|
||||
UIViewController *topVC = nil;
|
||||
UIWindow *keyWindow = nil;
|
||||
|
||||
// iOS 13+ 获取 keyWindow
|
||||
if (@available(iOS 13.0, *)) {
|
||||
NSSet<UIScene *> *scenes = [UIApplication sharedApplication].connectedScenes;
|
||||
for (UIScene *scene in scenes) {
|
||||
if ([scene isKindOfClass:[UIWindowScene class]]) {
|
||||
UIWindowScene *windowScene = (UIWindowScene *)scene;
|
||||
for (UIWindow *window in windowScene.windows) {
|
||||
if (window.isKeyWindow) {
|
||||
keyWindow = window;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (keyWindow) break;
|
||||
}
|
||||
} else {
|
||||
keyWindow = [UIApplication sharedApplication].keyWindow;
|
||||
}
|
||||
|
||||
topVC = keyWindow.rootViewController;
|
||||
while (topVC.presentedViewController) {
|
||||
topVC = topVC.presentedViewController;
|
||||
}
|
||||
return topVC;
|
||||
}
|
||||
|
||||
- (void)showInViewController:(UIViewController *)viewController callback:(void (^)(SCPlayerConfig * _Nonnull))callback {
|
||||
self.callback = callback;
|
||||
|
||||
// 直接添加到 viewController 的 view 上,而不是 window
|
||||
self.frame = viewController.view.bounds;
|
||||
[viewController.view addSubview:self];
|
||||
|
||||
// 动画显示
|
||||
self.alpha = 0;
|
||||
_backgroundView.transform = CGAffineTransformMakeScale(0.8, 0.8);
|
||||
|
||||
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
self.alpha = 1;
|
||||
self.backgroundView.transform = CGAffineTransformIdentity;
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)dismiss {
|
||||
[UIView animateWithDuration:0.2 animations:^{
|
||||
self.alpha = 0;
|
||||
self.backgroundView.transform = CGAffineTransformMakeScale(0.8, 0.8);
|
||||
} completion:^(BOOL finished) {
|
||||
[self removeFromSuperview];
|
||||
}];
|
||||
}
|
||||
|
||||
// 背景点击处理
|
||||
- (void)backgroundTapped:(UITapGestureRecognizer *)gesture {
|
||||
CGPoint location = [gesture locationInView:self];
|
||||
|
||||
// 判断点击位置是否在内容区域内
|
||||
if (!CGRectContainsPoint(_backgroundView.frame, location)) {
|
||||
// 点击了背景区域,关闭弹窗
|
||||
NSLog(@"📱 点击背景关闭配置弹窗");
|
||||
[self dismiss];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)loadSavedConfig {
|
||||
SCPlayerConfig *savedConfig = [SCPlayerConfig loadFromUserDefaults];
|
||||
|
||||
// 设置协议选择器
|
||||
_protocolSegment.selectedSegmentIndex = (savedConfig.protocol == SellyLiveMode_RTMP) ? 0 : 1;
|
||||
|
||||
// 设置 streamId
|
||||
if (savedConfig.streamId && savedConfig.streamId.length > 0) {
|
||||
_streamIdField.text = savedConfig.streamId;
|
||||
}
|
||||
|
||||
// 本地服务器开关
|
||||
_localServerSwitch.on = AVConfigManager.sharedManager.localServerEnabled;
|
||||
}
|
||||
|
||||
#pragma mark - UITextFieldDelegate
|
||||
|
||||
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
|
||||
[textField resignFirstResponder];
|
||||
return YES;
|
||||
}
|
||||
|
||||
#pragma mark - Keyboard Notifications
|
||||
|
||||
- (void)registerKeyboardNotifications {
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(keyboardWillShow:)
|
||||
name:UIKeyboardWillShowNotification
|
||||
object:nil];
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(keyboardWillHide:)
|
||||
name:UIKeyboardWillHideNotification
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (void)keyboardWillShow:(NSNotification *)notification {
|
||||
NSDictionary *userInfo = notification.userInfo;
|
||||
|
||||
// 获取键盘高度
|
||||
CGRect keyboardFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
|
||||
CGFloat keyboardHeight = keyboardFrame.size.height;
|
||||
|
||||
// 获取动画时长和曲线
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
UIViewAnimationCurve curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
|
||||
|
||||
// 计算输入框在屏幕上的位置
|
||||
CGRect textFieldFrame = [_streamIdField convertRect:_streamIdField.bounds toView:self];
|
||||
CGFloat textFieldBottom = CGRectGetMaxY(textFieldFrame);
|
||||
|
||||
// 计算需要的偏移量
|
||||
CGFloat visibleHeight = self.bounds.size.height - keyboardHeight;
|
||||
|
||||
// 给输入框下方留出一些空间(比如 20pt)
|
||||
CGFloat desiredSpace = 20;
|
||||
CGFloat offset = 0;
|
||||
|
||||
if (textFieldBottom + desiredSpace > visibleHeight) {
|
||||
// 输入框被键盘遮挡了,需要向上移动
|
||||
offset = -(textFieldBottom + desiredSpace - visibleHeight);
|
||||
}
|
||||
|
||||
// 更新约束
|
||||
[self.backgroundViewCenterYConstraint setOffset:offset];
|
||||
|
||||
// 动画更新布局
|
||||
[UIView animateWithDuration:duration delay:0 options:(curve << 16) animations:^{
|
||||
[self layoutIfNeeded];
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)keyboardWillHide:(NSNotification *)notification {
|
||||
NSDictionary *userInfo = notification.userInfo;
|
||||
|
||||
// 获取动画时长和曲线
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
UIViewAnimationCurve curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
|
||||
|
||||
// 恢复原始位置
|
||||
[self.backgroundViewCenterYConstraint setOffset:30];
|
||||
|
||||
// 动画更新布局
|
||||
[UIView animateWithDuration:duration delay:0 options:(curve << 16) animations:^{
|
||||
[self layoutIfNeeded];
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
29
Example/SellyCloudSDK/Play/SCPlayerDebugView.h
Normal file
29
Example/SellyCloudSDK/Play/SCPlayerDebugView.h
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SCPlayerDebugView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 07/1/26.
|
||||
// Copyright © 2026 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SCPlayerDebugView : UIView
|
||||
|
||||
/// 添加一条调试信息(自动添加时间戳)
|
||||
- (void)appendLog:(NSString *)message;
|
||||
|
||||
/// 添加一条调试信息(带自定义标签,如 ⚠️ 🔴 等)
|
||||
- (void)appendLog:(NSString *)message withPrefix:(nullable NSString *)prefix;
|
||||
|
||||
/// 清空所有日志
|
||||
- (void)clearLogs;
|
||||
|
||||
/// 展开状态(默认为 YES)
|
||||
@property (nonatomic, assign) BOOL isExpanded;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
260
Example/SellyCloudSDK/Play/SCPlayerDebugView.m
Normal file
260
Example/SellyCloudSDK/Play/SCPlayerDebugView.m
Normal file
@@ -0,0 +1,260 @@
|
||||
//
|
||||
// SCPlayerDebugView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 07/1/26.
|
||||
// Copyright © 2026 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SCPlayerDebugView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface SCPlayerDebugView ()
|
||||
@property (nonatomic, strong) UIVisualEffectView *blurView;
|
||||
@property (nonatomic, strong) UIView *headerView;
|
||||
@property (nonatomic, strong) UIView *contentView;
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UILabel *logLabel;
|
||||
@property (nonatomic, strong) UIButton *toggleButton;
|
||||
@property (nonatomic, strong) UIButton *clearButton;
|
||||
|
||||
@property (nonatomic, strong) MASConstraint *contentViewHeightConstraint;
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *logs;
|
||||
@property (nonatomic, strong) NSDateFormatter *dateFormatter;
|
||||
@end
|
||||
|
||||
@implementation SCPlayerDebugView
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_isExpanded = YES; // 默认展开
|
||||
_logs = [NSMutableArray array];
|
||||
|
||||
// 日期格式化器(用于时间戳)
|
||||
_dateFormatter = [[NSDateFormatter alloc] init];
|
||||
_dateFormatter.dateFormat = @"HH:mm:ss.SSS";
|
||||
|
||||
[self setupView];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupView {
|
||||
// 毛玻璃背景
|
||||
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemUltraThinMaterialDark];
|
||||
_blurView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
|
||||
_blurView.layer.cornerRadius = 12;
|
||||
_blurView.layer.masksToBounds = YES;
|
||||
[self addSubview:_blurView];
|
||||
[_blurView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
// 边框
|
||||
self.layer.cornerRadius = 12;
|
||||
self.layer.borderWidth = 0.5;
|
||||
self.layer.borderColor = [[UIColor whiteColor] colorWithAlphaComponent:0.3].CGColor;
|
||||
|
||||
// 标题栏(可点击)
|
||||
_headerView = [[UIView alloc] init];
|
||||
[_blurView.contentView addSubview:_headerView];
|
||||
[_headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.left.right.equalTo(_blurView.contentView);
|
||||
make.height.offset(44);
|
||||
}];
|
||||
|
||||
// 添加点击手势
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleButtonTapped)];
|
||||
[_headerView addGestureRecognizer:tap];
|
||||
|
||||
// 标题
|
||||
_titleLabel = [[UILabel alloc] init];
|
||||
_titleLabel.text = @"🐛 调试日志";
|
||||
_titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
_titleLabel.textColor = [UIColor whiteColor];
|
||||
[_headerView addSubview:_titleLabel];
|
||||
[_titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(_headerView);
|
||||
make.left.offset(12);
|
||||
}];
|
||||
|
||||
// 清空按钮
|
||||
_clearButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_clearButton setImage:[UIImage systemImageNamed:@"trash.circle.fill"] forState:UIControlStateNormal];
|
||||
_clearButton.tintColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8];
|
||||
[_clearButton addTarget:self action:@selector(clearButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[_headerView addSubview:_clearButton];
|
||||
[_clearButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(_headerView);
|
||||
make.right.offset(-44);
|
||||
make.width.height.offset(24);
|
||||
}];
|
||||
|
||||
// 展开/收起按钮
|
||||
_toggleButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
// 默认展开状态,显示向上箭头(表示可以收起)
|
||||
[_toggleButton setImage:[UIImage systemImageNamed:@"chevron.up.circle.fill"] forState:UIControlStateNormal];
|
||||
_toggleButton.tintColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8];
|
||||
[_toggleButton addTarget:self action:@selector(toggleButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[_headerView addSubview:_toggleButton];
|
||||
[_toggleButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(_headerView);
|
||||
make.right.offset(-12);
|
||||
make.width.height.offset(24);
|
||||
}];
|
||||
|
||||
// 分割线
|
||||
UIView *separatorLine = [[UIView alloc] init];
|
||||
separatorLine.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.2];
|
||||
[_blurView.contentView addSubview:separatorLine];
|
||||
[separatorLine mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(_headerView.mas_bottom);
|
||||
make.left.offset(12);
|
||||
make.right.offset(-12);
|
||||
make.height.offset(0.5);
|
||||
}];
|
||||
|
||||
// 内容容器
|
||||
_contentView = [[UIView alloc] init];
|
||||
_contentView.clipsToBounds = YES;
|
||||
[_blurView.contentView addSubview:_contentView];
|
||||
[_contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(separatorLine.mas_bottom);
|
||||
make.left.right.bottom.equalTo(_blurView.contentView);
|
||||
// 保存高度约束的引用,用于动态调整
|
||||
self.contentViewHeightConstraint = make.height.offset(200); // 默认高度
|
||||
}];
|
||||
|
||||
// ScrollView 用于滚动内容
|
||||
_scrollView = [[UIScrollView alloc] init];
|
||||
_scrollView.showsVerticalScrollIndicator = YES;
|
||||
_scrollView.showsHorizontalScrollIndicator = NO;
|
||||
_scrollView.alwaysBounceVertical = YES;
|
||||
[_contentView addSubview:_scrollView];
|
||||
[_scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_contentView).insets(UIEdgeInsetsMake(8, 12, 8, 12));
|
||||
}];
|
||||
|
||||
// 日志文本标签
|
||||
_logLabel = [[UILabel alloc] init];
|
||||
_logLabel.font = [UIFont monospacedSystemFontOfSize:10 weight:UIFontWeightRegular];
|
||||
_logLabel.textColor = [UIColor whiteColor];
|
||||
_logLabel.numberOfLines = 0; // 多行显示
|
||||
_logLabel.lineBreakMode = NSLineBreakByCharWrapping;
|
||||
_logLabel.text = @""; // 初始为空
|
||||
[_scrollView addSubview:_logLabel];
|
||||
[_logLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_scrollView);
|
||||
make.width.equalTo(_scrollView); // 限制宽度,允许文本换行
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleButtonTapped {
|
||||
// 切换状态
|
||||
[self setIsExpanded:!self.isExpanded];
|
||||
}
|
||||
|
||||
- (void)clearButtonTapped {
|
||||
[self clearLogs];
|
||||
|
||||
// 触觉反馈
|
||||
UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[feedback impactOccurred];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)appendLog:(NSString *)message {
|
||||
[self appendLog:message withPrefix:nil];
|
||||
}
|
||||
|
||||
- (void)appendLog:(NSString *)message withPrefix:(nullable NSString *)prefix {
|
||||
if (!message || message.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成时间戳
|
||||
NSString *timestamp = [self.dateFormatter stringFromDate:[NSDate date]];
|
||||
|
||||
// 构建完整的日志条目
|
||||
NSString *logEntry;
|
||||
// if (prefix && prefix.length > 0) {
|
||||
// logEntry = [NSString stringWithFormat:@"[%@] %@ %@", timestamp, prefix, message];
|
||||
// } else {
|
||||
logEntry = [NSString stringWithFormat:@"[%@] %@", timestamp, message];
|
||||
// }
|
||||
|
||||
// 添加到日志数组
|
||||
[self.logs addObject:logEntry];
|
||||
|
||||
// 限制日志数量(最多保留 200 条)
|
||||
if (self.logs.count > 200) {
|
||||
[self.logs removeObjectAtIndex:0];
|
||||
}
|
||||
|
||||
// 更新 UI
|
||||
[self updateLogDisplay];
|
||||
}
|
||||
|
||||
- (void)clearLogs {
|
||||
[self.logs removeAllObjects];
|
||||
[self updateLogDisplay];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)updateLogDisplay {
|
||||
// 将所有日志拼接成一个字符串
|
||||
NSString *allLogs = [self.logs componentsJoinedByString:@"\n"];
|
||||
self.logLabel.text = allLogs;
|
||||
|
||||
// 更新布局
|
||||
[self.logLabel mas_updateConstraints:^(MASConstraintMaker *make) {
|
||||
// 强制刷新高度
|
||||
}];
|
||||
[self.logLabel layoutIfNeeded];
|
||||
|
||||
// 滚动到底部(显示最新的日志)
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
CGFloat bottomOffset = MAX(0, self.scrollView.contentSize.height - self.scrollView.bounds.size.height);
|
||||
[self.scrollView setContentOffset:CGPointMake(0, bottomOffset) animated:YES];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)setIsExpanded:(BOOL)isExpanded {
|
||||
if (_isExpanded == isExpanded) {
|
||||
return; // 状态相同,不执行任何操作
|
||||
}
|
||||
|
||||
_isExpanded = isExpanded;
|
||||
|
||||
// 执行动画更新 UI
|
||||
CGFloat expandedHeight = 200; // 展开时的高度
|
||||
CGFloat collapsedHeight = 0; // 收起时的高度
|
||||
|
||||
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5 options:UIViewAnimationOptionCurveEaseInOut animations:^{
|
||||
if (self.isExpanded) {
|
||||
// 展开状态:显示向上箭头(表示可以收起)
|
||||
[self.toggleButton setImage:[UIImage systemImageNamed:@"chevron.up.circle.fill"] forState:UIControlStateNormal];
|
||||
[self.contentViewHeightConstraint setOffset:expandedHeight];
|
||||
} else {
|
||||
// 收起状态:显示向下箭头(表示可以展开)
|
||||
[self.toggleButton setImage:[UIImage systemImageNamed:@"chevron.down.circle.fill"] forState:UIControlStateNormal];
|
||||
[self.contentViewHeightConstraint setOffset:collapsedHeight];
|
||||
}
|
||||
|
||||
// 显示/隐藏内容(用 alpha 做淡入淡出效果)
|
||||
self.contentView.alpha = self.isExpanded ? 1.0 : 0.0;
|
||||
|
||||
// 更新约束触发布局更新
|
||||
[self.superview layoutIfNeeded];
|
||||
} completion:nil];
|
||||
|
||||
// 触觉反馈
|
||||
UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[feedback impactOccurred];
|
||||
}
|
||||
|
||||
@end
|
||||
20
Example/SellyCloudSDK/Play/SCVodVideoPlayerViewController.h
Normal file
20
Example/SellyCloudSDK/Play/SCVodVideoPlayerViewController.h
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// SCVodVideoPlayerViewController.h
|
||||
// SellyCloudSDK_Example
|
||||
// 点播播放器
|
||||
// Created by Caleb on 1/7/26.
|
||||
// Copyright © 2026 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import "AVLiveStreamModel.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SCVodVideoPlayerViewController : UIViewController
|
||||
// 移除外部传入的属性,改为内部配置
|
||||
- (instancetype)initWithLiveStream:(AVLiveStreamModel *)stream;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
612
Example/SellyCloudSDK/Play/SCVodVideoPlayerViewController.m
Normal file
612
Example/SellyCloudSDK/Play/SCVodVideoPlayerViewController.m
Normal file
@@ -0,0 +1,612 @@
|
||||
//
|
||||
// SCVodVideoPlayerViewController.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 1/7/26.
|
||||
// Copyright © 2026 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SCVodVideoPlayerViewController.h"
|
||||
#import "SCPlayerConfigView.h"
|
||||
#import "AVLiveStreamModel.h"
|
||||
#import "AVConstants.h"
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import <Masonry/Masonry.h>
|
||||
#import "SCLiveItemContainerView.h"
|
||||
#import <Photos/Photos.h>
|
||||
#import "SCPlayerDebugView.h"
|
||||
|
||||
@interface SCVodVideoPlayerViewController ()<SellyVodPlayerDelegate>
|
||||
@property (nonatomic, strong) SellyVodVideoPlayer *player;
|
||||
@property (nonatomic, strong) UIView *playerContainerView;
|
||||
@property (nonatomic, strong) SCLiveItemContainerView *containerView;
|
||||
@property (nonatomic, strong) SCPlayerConfig *currentConfig;
|
||||
@property (nonatomic, strong) UIButton *closeButton;
|
||||
@property (nonatomic, strong) SCPlayerDebugView *debugView;
|
||||
|
||||
// 进度条相关
|
||||
@property (nonatomic, strong) UIView *progressContainerView;
|
||||
@property (nonatomic, strong) UISlider *progressSlider;
|
||||
@property (nonatomic, strong) UILabel *currentTimeLabel;
|
||||
@property (nonatomic, strong) UILabel *totalTimeLabel;
|
||||
@property (nonatomic, assign) BOOL isDraggingProgress; // 标记用户是否正在拖动进度条
|
||||
|
||||
// 保存按钮 model 的引用,用于更新状态
|
||||
@property (nonatomic, strong) SCLiveItemModel *playPauseModel;
|
||||
@property (nonatomic, strong) SCLiveItemModel *muteModel;
|
||||
@property (nonatomic, strong) AVLiveStreamModel *streamModel;
|
||||
|
||||
@end
|
||||
|
||||
@implementation SCVodVideoPlayerViewController
|
||||
|
||||
- (instancetype)initWithLiveStream:(AVLiveStreamModel *)stream {
|
||||
self = [super init];
|
||||
self.streamModel = stream;
|
||||
[self convertStreamModelToPlayerConfig:stream];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = UIColor.blackColor;
|
||||
|
||||
// 隐藏导航栏
|
||||
self.navigationController.navigationBarHidden = YES;
|
||||
|
||||
// 添加播放器容器
|
||||
[self.view addSubview:self.playerContainerView];
|
||||
[self.playerContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
|
||||
// 添加关闭按钮(右上角)
|
||||
[self setupCloseButton];
|
||||
|
||||
// 添加调试视图(右上角,关闭按钮下方)
|
||||
[self setupDebugView];
|
||||
|
||||
// 添加控制按钮容器
|
||||
[self.view addSubview:self.containerView];
|
||||
[self.containerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.offset(0);
|
||||
make.bottom.offset(-48);
|
||||
make.height.offset(100);
|
||||
}];
|
||||
|
||||
// 添加播放进度条(在 containerView 之后添加,这样可以引用它)
|
||||
[self setupProgressView];
|
||||
|
||||
// 禁止息屏
|
||||
[UIApplication sharedApplication].idleTimerDisabled = YES;
|
||||
|
||||
[self startPlayWithConfig:self.currentConfig];
|
||||
}
|
||||
|
||||
- (void)convertStreamModelToPlayerConfig:(AVLiveStreamModel *)stream {
|
||||
if (!stream) {
|
||||
NSLog(@"❌ stream model is nil");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建 SCPlayerConfig
|
||||
SCPlayerConfig *config = [[SCPlayerConfig alloc] init];
|
||||
config.streamId = stream.stream ? stream.stream:stream.url;
|
||||
|
||||
// 保存配置并开始播放
|
||||
self.currentConfig = config;
|
||||
}
|
||||
|
||||
- (void)setupCloseButton {
|
||||
_closeButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_closeButton setImage:[UIImage systemImageNamed:@"xmark.circle.fill"] forState:UIControlStateNormal];
|
||||
_closeButton.tintColor = [UIColor whiteColor];
|
||||
[_closeButton addTarget:self action:@selector(closeButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:_closeButton];
|
||||
[_closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.offset(-16);
|
||||
make.top.offset(50);
|
||||
make.width.height.offset(44);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)setupDebugView {
|
||||
_debugView = [[SCPlayerDebugView alloc] init];
|
||||
[self.view addSubview:_debugView];
|
||||
[_debugView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.offset(-16);
|
||||
make.top.equalTo(self.closeButton.mas_bottom).offset(16);
|
||||
make.width.offset(300);
|
||||
}];
|
||||
|
||||
// 添加初始日志
|
||||
[_debugView appendLog:@"调试器已初始化" withPrefix:@"✅"];
|
||||
[_debugView appendLog:@"VOD 点播播放器" withPrefix:@"🎬"];
|
||||
}
|
||||
|
||||
- (void)setupProgressView {
|
||||
// 创建进度条容器
|
||||
_progressContainerView = [[UIView alloc] init];
|
||||
_progressContainerView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.5];
|
||||
[self.view addSubview:_progressContainerView];
|
||||
|
||||
[_progressContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.offset(0);
|
||||
make.bottom.equalTo(self.containerView.mas_top).offset(-10);
|
||||
make.height.offset(50);
|
||||
}];
|
||||
|
||||
// 当前时间标签
|
||||
_currentTimeLabel = [[UILabel alloc] init];
|
||||
_currentTimeLabel.textColor = [UIColor whiteColor];
|
||||
_currentTimeLabel.font = [UIFont systemFontOfSize:12];
|
||||
_currentTimeLabel.text = @"00:00";
|
||||
[_progressContainerView addSubview:_currentTimeLabel];
|
||||
|
||||
[_currentTimeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.offset(16);
|
||||
make.centerY.offset(0);
|
||||
make.width.offset(45);
|
||||
}];
|
||||
|
||||
// 总时长标签
|
||||
_totalTimeLabel = [[UILabel alloc] init];
|
||||
_totalTimeLabel.textColor = [UIColor whiteColor];
|
||||
_totalTimeLabel.font = [UIFont systemFontOfSize:12];
|
||||
_totalTimeLabel.text = @"00:00";
|
||||
_totalTimeLabel.textAlignment = NSTextAlignmentRight;
|
||||
[_progressContainerView addSubview:_totalTimeLabel];
|
||||
|
||||
[_totalTimeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.offset(-16);
|
||||
make.centerY.offset(0);
|
||||
make.width.offset(45);
|
||||
}];
|
||||
|
||||
// 进度条
|
||||
_progressSlider = [[UISlider alloc] init];
|
||||
_progressSlider.minimumValue = 0;
|
||||
_progressSlider.maximumValue = 1;
|
||||
_progressSlider.value = 0;
|
||||
_progressSlider.minimumTrackTintColor = [UIColor systemBlueColor];
|
||||
_progressSlider.maximumTrackTintColor = [[UIColor whiteColor] colorWithAlphaComponent:0.3];
|
||||
|
||||
// 添加拖动事件
|
||||
[_progressSlider addTarget:self action:@selector(progressSliderTouchDown:) forControlEvents:UIControlEventTouchDown];
|
||||
[_progressSlider addTarget:self action:@selector(progressSliderValueChanged:) forControlEvents:UIControlEventValueChanged];
|
||||
[_progressSlider addTarget:self action:@selector(progressSliderTouchUpInside:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[_progressSlider addTarget:self action:@selector(progressSliderTouchUpOutside:) forControlEvents:UIControlEventTouchUpOutside];
|
||||
|
||||
[_progressContainerView addSubview:_progressSlider];
|
||||
|
||||
[_progressSlider mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.currentTimeLabel.mas_right).offset(8);
|
||||
make.right.equalTo(self.totalTimeLabel.mas_left).offset(-8);
|
||||
make.centerY.offset(0);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)closeButtonTapped {
|
||||
// 停止播放
|
||||
if (self.player) {
|
||||
[self.player stop];
|
||||
}
|
||||
|
||||
// 恢复导航栏
|
||||
self.navigationController.navigationBarHidden = NO;
|
||||
|
||||
// 返回上一页
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
[self.view bringSubviewToFront:self.progressContainerView];
|
||||
[self.view bringSubviewToFront:self.containerView];
|
||||
[self.view bringSubviewToFront:self.debugView];
|
||||
[self.view bringSubviewToFront:self.closeButton];
|
||||
}
|
||||
|
||||
- (void)showConfigView {
|
||||
SCPlayerConfigView *configView = [[SCPlayerConfigView alloc] init];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[configView showInViewController:self callback:^(SCPlayerConfig *config) {
|
||||
weakSelf.currentConfig = config;
|
||||
[weakSelf startPlayWithConfig:config];
|
||||
}];
|
||||
|
||||
// 配置视图现在添加到 self.view 上,确保关闭按钮在最上层
|
||||
[self.view bringSubviewToFront:self.closeButton];
|
||||
}
|
||||
|
||||
- (void)startPlayWithConfig:(SCPlayerConfig *)config {
|
||||
// 如果已经有播放器在运行,先停止
|
||||
if (self.player) {
|
||||
[self.player stop];
|
||||
[self.player setRenderView:nil];
|
||||
self.player = nil;
|
||||
}
|
||||
self.currentConfig = config;
|
||||
|
||||
SellyVodVideoPlayer *player = [[SellyVodVideoPlayer alloc] init];
|
||||
player.delegate = self;
|
||||
player.scaleMode = SellyPlayerScalingModeAspectFit;
|
||||
player.shouldAutoplay = YES;
|
||||
[player setRenderView:self.playerContainerView];
|
||||
|
||||
// 确保初始音量为正常状态(未静音)
|
||||
if (player.playbackVolume == 0) {
|
||||
player.playbackVolume = 1.0;
|
||||
}
|
||||
self.player = player;
|
||||
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"初始化播放器: streamId=%@", config.streamId] withPrefix:@"🎬"];
|
||||
[self.debugView appendLog:@"自动播放: YES" withPrefix:@"⚙️"];
|
||||
|
||||
[self startPlay];
|
||||
|
||||
// 设置控制按钮
|
||||
[self setupControlButtons];
|
||||
}
|
||||
|
||||
- (void)startPlay {
|
||||
// 判断是 URL 还是 streamId
|
||||
SCPlayerConfig *config = self.currentConfig;
|
||||
if ([config.streamId hasPrefix:@"rtmp://"] ||
|
||||
[config.streamId hasPrefix:@"http://"] ||
|
||||
[config.streamId hasPrefix:@"https://"] ||
|
||||
[config.streamId hasPrefix:@"file://"] ||
|
||||
[config.streamId hasPrefix:@"/"]) {
|
||||
// 完整 URL 或本地文件路径
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"开始播放 URL: %@", config.streamId] withPrefix:@"▶️"];
|
||||
[self.player startPlayUrl:config.streamId];
|
||||
} else {
|
||||
[self.debugView appendLog:@"无效的播放地址" withPrefix:@"⚠️"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setupControlButtons {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
NSMutableArray *items = NSMutableArray.new;
|
||||
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypePlayPause;
|
||||
model.title = @"播放/暂停";
|
||||
model.isSelected = NO;
|
||||
model.clickCallback = ^{
|
||||
if (weakSelf.player.isPlaying) {
|
||||
[weakSelf pause];
|
||||
} else {
|
||||
[weakSelf start];
|
||||
}
|
||||
[weakSelf updatePlayPauseButtonState];
|
||||
};
|
||||
self.playPauseModel = model;
|
||||
[items addObject:model];
|
||||
}
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypeVolume;
|
||||
model.title = @"静音";
|
||||
|
||||
// 获取当前音量状态
|
||||
CGFloat currentVolume = self.player.playbackVolume;
|
||||
model.isSelected = (currentVolume == 0);
|
||||
|
||||
NSLog(@"📢 静音按钮初始化 - 当前音量: %.2f, isSelected: %d", currentVolume, model.isSelected);
|
||||
|
||||
model.clickCallback = ^{
|
||||
weakSelf.player.playbackVolume = 1 - weakSelf.player.playbackVolume;
|
||||
NSLog(@"📢 点击静音按钮 - 新音量: %.2f", weakSelf.player.playbackVolume);
|
||||
[weakSelf updateMuteButtonState];
|
||||
};
|
||||
self.muteModel = model;
|
||||
[items addObject:model];
|
||||
}
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypeScreenshot;
|
||||
model.title = @"截图";
|
||||
model.clickCallback = ^{
|
||||
UIImage *screenshot = weakSelf.player.getCurrentImage;
|
||||
if (screenshot) {
|
||||
[weakSelf.debugView appendLog:@"截图成功,正在保存..." withPrefix:@"📸"];
|
||||
} else {
|
||||
[weakSelf.debugView appendLog:@"截图失败:无图像" withPrefix:@"⚠️"];
|
||||
}
|
||||
[weakSelf saveCurrentFrameToPhotoAlbum:screenshot];
|
||||
};
|
||||
[items addObject:model];
|
||||
}
|
||||
{
|
||||
SCLiveItemModel *model = SCLiveItemModel.new;
|
||||
model.type = SCLiveItemTypeSeekForward;
|
||||
model.title = @"快进10秒";
|
||||
model.clickCallback = ^{
|
||||
// 快进10秒
|
||||
NSTimeInterval currentTime = weakSelf.player.currentPlaybackTime;
|
||||
NSTimeInterval duration = weakSelf.player.duration;
|
||||
NSTimeInterval newTime = currentTime + 10.0;
|
||||
|
||||
// 确保不超过视频时长
|
||||
if (duration > 0 && newTime > duration) {
|
||||
newTime = duration;
|
||||
}
|
||||
|
||||
weakSelf.player.currentPlaybackTime = newTime;
|
||||
NSLog(@"⏩ 快进 - 从 %.2f 秒到 %.2f 秒", currentTime, newTime);
|
||||
[weakSelf.debugView appendLog:[NSString stringWithFormat:@"快进: %.2fs → %.2fs (总时长: %.2fs)",
|
||||
currentTime, newTime, duration] withPrefix:@"⏩"];
|
||||
};
|
||||
[items addObject:model];
|
||||
}
|
||||
|
||||
self.containerView.models = items;
|
||||
[self updatePlayPauseButtonState];
|
||||
}
|
||||
|
||||
- (void)updatePlayPauseButtonState {
|
||||
self.playPauseModel.isSelected = self.player.isPlaying;
|
||||
// 刷新 UI
|
||||
self.containerView.models = self.containerView.models;
|
||||
}
|
||||
|
||||
- (void)updateMuteButtonState {
|
||||
self.muteModel.isSelected = (self.player.playbackVolume == 0);
|
||||
// 刷新 UI
|
||||
self.containerView.models = self.containerView.models;
|
||||
}
|
||||
|
||||
#pragma mark - Player Control
|
||||
|
||||
- (NSString *)stringFromPlayerState:(SellyPlayerState)state {
|
||||
switch (state) {
|
||||
case SellyPlayerStateIdle:
|
||||
return @"空闲 (Idle)";
|
||||
case SellyPlayerStateConnecting:
|
||||
return @"连接中 (Connecting)";
|
||||
case SellyPlayerStatePlaying:
|
||||
return @"播放中 (Playing)";
|
||||
case SellyPlayerStatePaused:
|
||||
return @"暂停 (Paused)";
|
||||
case SellyPlayerStateStoppedOrEnded:
|
||||
return @"已停止/结束 (Stopped/Ended)";
|
||||
case SellyPlayerStateFailed:
|
||||
return @"失败 (Failed)";
|
||||
default:
|
||||
return [NSString stringWithFormat:@"未知状态 (%ld)", (long)state];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)start {
|
||||
[self.debugView appendLog:@"恢复播放" withPrefix:@"▶️"];
|
||||
[self.player resume];
|
||||
}
|
||||
|
||||
- (void)pause {
|
||||
[self.debugView appendLog:@"暂停播放" withPrefix:@"⏸️"];
|
||||
[self.player pause];
|
||||
}
|
||||
|
||||
#pragma mark - Progress Control
|
||||
|
||||
- (NSString *)formatTime:(NSTimeInterval)timeInterval {
|
||||
NSInteger totalSeconds = (NSInteger)timeInterval;
|
||||
NSInteger hours = totalSeconds / 3600;
|
||||
NSInteger minutes = (totalSeconds % 3600) / 60;
|
||||
NSInteger seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return [NSString stringWithFormat:@"%02ld:%02ld:%02ld", (long)hours, (long)minutes, (long)seconds];
|
||||
} else {
|
||||
return [NSString stringWithFormat:@"%02ld:%02ld", (long)minutes, (long)seconds];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)progressSliderTouchDown:(UISlider *)slider {
|
||||
self.isDraggingProgress = YES;
|
||||
[self.debugView appendLog:@"开始拖动进度条" withPrefix:@"👆"];
|
||||
}
|
||||
|
||||
- (void)progressSliderValueChanged:(UISlider *)slider {
|
||||
if (self.isDraggingProgress) {
|
||||
// 更新当前时间标签显示
|
||||
self.currentTimeLabel.text = [self formatTime:slider.value];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)progressSliderTouchUpInside:(UISlider *)slider {
|
||||
[self seekToTime:slider.value];
|
||||
}
|
||||
|
||||
- (void)progressSliderTouchUpOutside:(UISlider *)slider {
|
||||
[self seekToTime:slider.value];
|
||||
}
|
||||
|
||||
- (void)seekToTime:(NSTimeInterval)time {
|
||||
self.isDraggingProgress = NO;
|
||||
|
||||
if (self.player) {
|
||||
self.player.currentPlaybackTime = time;
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"跳转到: %@", [self formatTime:time]] withPrefix:@"⏭️"];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - SellyPlayerManagerDelegate
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player prepareToPlayChanged:(BOOL)prepare {
|
||||
NSLog(@"Player prepare changed: %d", prepare);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"prepareToPlayChanged: %@", prepare ? @"YES" : @"NO"] withPrefix:@"🔧"];
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player playbackDidFinished:(NSDictionary *)resultInfo {
|
||||
NSLog(@"Playback finished: %@", resultInfo);
|
||||
[self.debugView appendLog:@"播放已结束" withPrefix:@"⏹️"];
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player playbackStateChanged:(SellyPlayerState)state {
|
||||
NSString *stateString = [self stringFromPlayerState:state];
|
||||
NSLog(@"🎬 播放状态变更: %@ (rawValue: %ld)", stateString, (long)state);
|
||||
|
||||
// 添加到调试器
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"状态变更: %@", stateString] withPrefix:@"🎬"];
|
||||
|
||||
// 更新播放/暂停按钮状态
|
||||
[self updatePlayPauseButtonState];
|
||||
|
||||
// 注意:不再需要手动管理进度条定时器
|
||||
// 播放器内部的定时器会自动处理,并通过 playbackProgressChanged 回调通知
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player onError:(NSError *)error {
|
||||
NSLog(@"Player error: %@", error.localizedDescription);
|
||||
|
||||
// 添加到调试器
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"错误: %@", error.localizedDescription] withPrefix:@"🔴"];
|
||||
|
||||
// 显示错误提示
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"播放错误"
|
||||
message:error.localizedDescription
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"重试" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
|
||||
// 如果有 streamModel 就重新播放,否则显示配置界面
|
||||
if (self.currentConfig) {
|
||||
[self startPlayWithConfig:self.currentConfig];
|
||||
} else {
|
||||
[self showConfigView];
|
||||
}
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
|
||||
[self closeButtonTapped];
|
||||
}]];
|
||||
[self presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player firstRemoteVideoFrame:(NSInteger)elapse {
|
||||
NSLog(@"###视频首帧加载耗时 == %ldms",elapse);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"视频首帧: %ldms", elapse] withPrefix:@"📹"];
|
||||
|
||||
self.progressSlider.maximumValue = self.player.duration;
|
||||
self.totalTimeLabel.text = [self formatTime:self.player.duration];
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player firstRemoteAudioFrame:(NSInteger)elapse {
|
||||
NSLog(@"###语音首帧加载耗时 == %ldms",elapse);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"音频首帧: %ldms", elapse] withPrefix:@"🔊"];
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player
|
||||
playbackProgressChanged:(NSTimeInterval)currentTime {
|
||||
// 播放进度回调(每秒触发一次)
|
||||
// 注意:暂停时 currentTime 保持不变,仍会继续回调
|
||||
|
||||
if (self.isDraggingProgress) {
|
||||
// 用户正在拖动进度条时,不更新界面
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取总时长(只需要一次性获取)
|
||||
NSTimeInterval duration = self.player.duration;
|
||||
|
||||
// 更新进度条和时间标签
|
||||
if (duration > 0) {
|
||||
self.progressSlider.value = currentTime;
|
||||
self.currentTimeLabel.text = [self formatTime:currentTime];
|
||||
}
|
||||
|
||||
// 可选:打印日志(建议只在需要调试时打开)
|
||||
// NSLog(@"⏱️ 播放进度: %.2f / %.2f 秒 (%.1f%%)", currentTime, duration, (currentTime/duration)*100);
|
||||
}
|
||||
|
||||
- (void)player:(SellyVodVideoPlayer *)player
|
||||
bufferProgressChanged:(NSTimeInterval)playableDuration {
|
||||
// 缓冲进度回调(缓冲进度变化时触发)
|
||||
// 即使在暂停状态,缓冲也可能继续进行
|
||||
|
||||
// 获取总时长(只需要一次性获取)
|
||||
NSTimeInterval duration = self.player.duration;
|
||||
|
||||
if (duration > 0) {
|
||||
CGFloat bufferProgress = playableDuration / duration;
|
||||
|
||||
// 可选:如果有缓冲进度条的话,可以在这里更新
|
||||
// self.bufferProgressView.progress = bufferProgress;
|
||||
|
||||
// NSLog(@"📶 缓冲进度: %.2f / %.2f 秒 (%.1f%%)", playableDuration, duration, bufferProgress * 100);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Screenshot
|
||||
|
||||
- (void)saveCurrentFrameToPhotoAlbum:(UIImage *)image {
|
||||
if (!image) {
|
||||
NSLog(@"当前没有图像可保存");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
||||
if (status == PHAuthorizationStatusDenied || status == PHAuthorizationStatusRestricted) {
|
||||
NSLog(@"无权限访问相册,请到设置中开启权限");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == PHAuthorizationStatusNotDetermined) {
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus newStatus) {
|
||||
if (newStatus == PHAuthorizationStatusAuthorized) {
|
||||
[self saveImage:image];
|
||||
}
|
||||
}];
|
||||
} else {
|
||||
[self saveImage:image];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)saveImage:(UIImage *)image {
|
||||
UIImageWriteToSavedPhotosAlbum(image, self, @selector(image:didFinishSavingWithError:contextInfo:), NULL);
|
||||
}
|
||||
|
||||
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo {
|
||||
if (error) {
|
||||
NSLog(@"保存失败:%@", error.localizedDescription);
|
||||
[self.debugView appendLog:[NSString stringWithFormat:@"保存失败: %@", error.localizedDescription] withPrefix:@"🔴"];
|
||||
} else {
|
||||
NSLog(@"已保存当前视频帧至相册");
|
||||
[self.debugView appendLog:@"已保存至相册" withPrefix:@"✅"];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Loading
|
||||
|
||||
- (UIView *)playerContainerView {
|
||||
if (!_playerContainerView) {
|
||||
_playerContainerView = [[UIView alloc] init];
|
||||
_playerContainerView.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
return _playerContainerView;
|
||||
}
|
||||
|
||||
- (SCLiveItemContainerView *)containerView {
|
||||
if (!_containerView) {
|
||||
_containerView = [[SCLiveItemContainerView alloc] init];
|
||||
}
|
||||
return _containerView;
|
||||
}
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (void)dealloc {
|
||||
// 不再需要手动停止定时器,播放器会自动管理
|
||||
[UIApplication sharedApplication].idleTimerDisabled = NO;
|
||||
NSLog(@"###%@ dealloc", NSStringFromClass(self.class));
|
||||
}
|
||||
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
[super viewDidDisappear:animated];
|
||||
|
||||
// 恢复导航栏
|
||||
self.navigationController.navigationBarHidden = NO;
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user