Files
SellyCloudSDK_demo/Example/SellyCloudSDK/Play/SCVodVideoPlayerViewController.m
2026-03-01 15:59:27 +08:00

627 lines
22 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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;
// 根据 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 {
// 默认使用 RTMPflv、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;
}
- (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://"]) {
// 完整 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