384 lines
14 KiB
Objective-C
384 lines
14 KiB
Objective-C
//
|
||
// SellyVideoCallViewController.m
|
||
// SellyCloudSDK_Example
|
||
//
|
||
// Created by Caleb on 3/11/25.
|
||
// Copyright © 2025 Caleb. All rights reserved.
|
||
//
|
||
|
||
#import "SellyVideoCallViewController.h"
|
||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||
#import "FUManager.h"
|
||
#import "UIView+SellyCloud.h"
|
||
#import "SellyCallPiPManager.h"
|
||
#import "TokenGenerator.h"
|
||
#import "SellyCallControlView.h"
|
||
#import "SellyCallStatsView.h"
|
||
#import <Masonry/Masonry.h>
|
||
|
||
@interface SellyVideoCallViewController ()<SellyRTCSessionDelegate, SellyCallControlViewDelegate>
|
||
@property (weak, nonatomic) IBOutlet UIView *localView;
|
||
@property (weak, nonatomic) IBOutlet UIView *remoteView;
|
||
@property (nonatomic, strong)SellyRTCSession *session;
|
||
|
||
@property (nonatomic, assign)BOOL localVideoEnable;
|
||
@property (nonatomic, assign)BOOL localAudioEnable;
|
||
@property (nonatomic, assign)BOOL speakerEnabled;
|
||
|
||
@property (nonatomic, strong)AVAudioPlayer *player;
|
||
|
||
@property (nonatomic, strong) SellyCallPiPManager *pipManager;
|
||
|
||
// 底部控制视图
|
||
@property (nonatomic, strong) SellyCallControlView *controlView;
|
||
// 统计信息视图
|
||
@property (nonatomic, strong) SellyCallStatsView *statsView;
|
||
@property (nonatomic, assign) BOOL remoteUserJoined; // 标记远端用户是否加入
|
||
@end
|
||
|
||
@implementation SellyVideoCallViewController
|
||
|
||
- (void)viewDidLoad {
|
||
[super viewDidLoad];
|
||
self.title = @"音视频单聊";
|
||
|
||
// 初始化状态
|
||
self.remoteUserJoined = NO;
|
||
self.speakerEnabled = YES; // 默认扬声器开启
|
||
self.localAudioEnable = YES; // 默认麦克风开启
|
||
|
||
// 隐藏远端视图,等对方加入后再显示
|
||
self.remoteView.hidden = YES;
|
||
|
||
// 设置底部控制视图
|
||
[self setupControlView];
|
||
|
||
// 设置统计信息视图
|
||
[self setupStatsView];
|
||
|
||
// Do any additional setup after loading the view from its nib.
|
||
SellyRTCVideoCanvas *localCanvas = SellyRTCVideoCanvas.new;
|
||
localCanvas.view = self.localView;
|
||
localCanvas.userId = SellyCloudManager.sharedInstance.userId;
|
||
[self.session setLocalCanvas:localCanvas];
|
||
|
||
|
||
self.session.delegate = self;
|
||
if (self.videoConfig == nil) {
|
||
self.videoConfig = SellyRTCVideoConfiguration.defaultConfig;
|
||
}
|
||
self.session.videoConfig = self.videoConfig;
|
||
|
||
//模拟先本地预览响铃,5s后接通的流程
|
||
[self.session startPreview];
|
||
//开启视频
|
||
[self onCameraClick:nil];
|
||
//模拟等待接听铃声
|
||
[self playSourceName:nil numberOfLoops:100];
|
||
|
||
//设置扬声器播放
|
||
{
|
||
//使用这种方案,通话接通前无法在receiver和speaker直接来回切换,强制扬声器
|
||
// [self.session setAudioOutput:AVAudioSessionPortOverrideSpeaker];
|
||
}
|
||
|
||
{
|
||
//使用这种方案,通话接通前可以在receiver和speaker直接来回切换
|
||
NSError *error;
|
||
[AVAudioSession.sharedInstance setCategory:AVAudioSessionCategoryPlayAndRecord mode:AVAudioSessionModeVoiceChat options:AVAudioSessionCategoryOptionDuckOthers|AVAudioSessionCategoryOptionAllowBluetooth|AVAudioSessionCategoryOptionMixWithOthers error:&error];
|
||
[self.session setAudioOutput:AVAudioSessionPortOverrideSpeaker];
|
||
}
|
||
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||
//模拟3s后接通
|
||
NSString *token = [TokenGenerator generateRTCCallTokenWithUserId:SellyCloudManager.sharedInstance.userId callId:self.channelId];
|
||
[self.session startWithChannelId:self.channelId token:token];
|
||
});
|
||
}
|
||
|
||
- (void)viewWillAppear:(BOOL)animated {
|
||
[super viewWillAppear:animated];
|
||
// 隐藏导航栏
|
||
[self.navigationController setNavigationBarHidden:YES animated:animated];
|
||
}
|
||
|
||
- (void)viewWillDisappear:(BOOL)animated {
|
||
[super viewWillDisappear:animated];
|
||
// 恢复导航栏
|
||
[self.navigationController setNavigationBarHidden:NO animated:animated];
|
||
}
|
||
|
||
- (void)setupControlView {
|
||
// 创建底部控制视图
|
||
self.controlView = [[SellyCallControlView alloc] init];
|
||
self.controlView.delegate = self;
|
||
|
||
// 单聊场景显示画中画按钮
|
||
self.controlView.showPiPButton = YES;
|
||
|
||
[self.view addSubview:self.controlView];
|
||
|
||
// 使用 Masonry 设置约束
|
||
[self.controlView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.leading.trailing.equalTo(self.view);
|
||
make.bottom.equalTo(self.view);
|
||
make.height.mas_equalTo(210); // 调整高度以适应两行按钮
|
||
}];
|
||
|
||
// 初始化按钮状态
|
||
[self.controlView updateSpeakerEnabled:self.speakerEnabled];
|
||
[self.controlView updateVideoEnabled:self.localVideoEnable];
|
||
[self.controlView updateMuteEnabled:!self.localAudioEnable];
|
||
[self.controlView updatePiPEnabled:NO]; // 初始画中画未激活
|
||
}
|
||
|
||
- (void)setupStatsView {
|
||
// 创建统计信息视图
|
||
self.statsView = [[SellyCallStatsView alloc] init];
|
||
self.statsView.hidden = YES; // 初始隐藏,通话接通后显示
|
||
[self.view addSubview:self.statsView];
|
||
|
||
// 使用 Masonry 设置约束 - 放在左上角
|
||
[self.statsView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.leading.equalTo(self.view).offset(16);
|
||
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop).offset(16);
|
||
make.width.mas_equalTo(200);
|
||
make.height.mas_equalTo(150);
|
||
}];
|
||
}
|
||
|
||
- (void)viewDidDisappear:(BOOL)animated {
|
||
[super viewDidDisappear:animated];
|
||
[self.session end];
|
||
[self QCM_stopRing];
|
||
|
||
//正常情况下退出通话要保持,这里只做演示
|
||
[self.pipManager invalidate];
|
||
self.pipManager = nil;
|
||
}
|
||
|
||
- (IBAction)onSpeakerClick:(id)sender {
|
||
//如果没有外接设备,可以直接调用这个方法在听筒和扬声器直接来回切换
|
||
AVAudioSessionPort currentPort = AVAudioSession.sharedInstance.currentRoute.outputs.firstObject.portType;
|
||
if ([currentPort isEqualToString:AVAudioSessionPortBuiltInSpeaker]) {
|
||
[self.session setAudioOutput:AVAudioSessionPortOverrideNone];
|
||
self.speakerEnabled = NO;
|
||
}
|
||
else {
|
||
[self.session setAudioOutput:AVAudioSessionPortOverrideSpeaker];
|
||
self.speakerEnabled = YES;
|
||
}
|
||
[self.controlView updateSpeakerEnabled:self.speakerEnabled];
|
||
}
|
||
|
||
- (IBAction)onCameraClick:(id)sender {
|
||
[self.session enableLocalVideo:!self.localVideoEnable];
|
||
self.localVideoEnable = !self.localVideoEnable;
|
||
[self.controlView updateVideoEnabled:self.localVideoEnable];
|
||
}
|
||
|
||
- (IBAction)onSwitchClick:(id)sender {
|
||
[self.session switchCamera];
|
||
}
|
||
|
||
- (IBAction)onMuteClick:(id)sender {
|
||
[self.session enableLocalAudio:!self.localAudioEnable];
|
||
self.localAudioEnable = !self.localAudioEnable;
|
||
[self.controlView updateMuteEnabled:!self.localAudioEnable];
|
||
}
|
||
|
||
- (IBAction)onHangupClick:(id)sender {
|
||
[self.navigationController popViewControllerAnimated:YES];
|
||
}
|
||
|
||
- (IBAction)onActionPIP:(id)sender {
|
||
if (@available(iOS 15.0, *)) {
|
||
if (self.pipManager.pipPossible) {
|
||
[self.pipManager togglePiP];
|
||
} else {
|
||
[self.view showToast:@"当前设备不支持画中画"];
|
||
}
|
||
} else {
|
||
[self.view showToast:@"iOS 15 以上才支持自定义 PiP"];
|
||
}
|
||
}
|
||
|
||
- (void)playSourceName:(NSString *)source numberOfLoops:(NSInteger)numberOfLoops {
|
||
NSString *url = [NSBundle.mainBundle pathForResource:@"call" ofType:@"caf"];
|
||
_player = [[AVAudioPlayer alloc] initWithContentsOfURL:[NSURL fileURLWithPath:url] error:nil];
|
||
_player.numberOfLoops = numberOfLoops;
|
||
[_player play];
|
||
}
|
||
|
||
- (void)QCM_stopRing {
|
||
if (_player && _player.isPlaying) {
|
||
[_player stop];
|
||
}
|
||
}
|
||
|
||
#pragma marks SellyRTCSessionDelegate
|
||
- (void)rtcSession:(SellyRTCSession *)session didReceiveMessage:(NSString *)message userId:(NSString *)userId {
|
||
NSLog(@"userId == %@ didReceiveMessage == %@",userId,message);
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session audioEnabled:(BOOL)enabled userId:(NSString *)userId {
|
||
NSLog(@"userId == %@ audioEnabled == %d",userId,enabled);
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session videoEnabled:(BOOL)enabled userId:(NSString *)userId {
|
||
NSLog(@"userId == %@ videoEnabled == %d",userId,enabled);
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session connectionStateChanged:(SellyRTCConnectState)state userId:(nullable NSString *)userId {
|
||
NSLog(@"ice.connectionStateChanged userId == %@ statu == %ld",userId,state);
|
||
// 初始化 PiP Manager
|
||
if (state == SellyRTCConnectStateConnected && !self.pipManager) {
|
||
self.pipManager = [[SellyCallPiPManager alloc] initWithRenderView:self.remoteView];
|
||
[self.pipManager setupIfNeeded];
|
||
}
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onRoomConnectionStateChanged:(SellyRoomConnectionState)state {
|
||
NSLog(@"onSocketStateChanged == %ld",(long)state);
|
||
}
|
||
|
||
- (CVPixelBufferRef)rtcSession:(SellyRTCSession *)session onCaptureVideoFrame:(CVPixelBufferRef)pixelBuffer {
|
||
CVPixelBufferRef afterBuffer = [FUManager.shareManager renderItemsToPixelBuffer:pixelBuffer];
|
||
return afterBuffer;
|
||
}
|
||
|
||
//返回false sdk内部将不会默认渲染改帧
|
||
- (BOOL)rtcSession:(SellyRTCSession *)session
|
||
onRenderVideoFrame:(SellyRTCVideoFrame *)videoFrame
|
||
userId:(NSString *)userId {
|
||
|
||
// 1. SDK 继续默认渲染到你设置的 canvas(localView/remoteView)
|
||
// 2. 同时,我们把远端的帧喂给 PiP layer。假设你想 PiP 显示远端 userId:
|
||
[self.pipManager feedVideoFrame:videoFrame];
|
||
return false; // 仍然让 SDK 内部按 canvas 渲染
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onError:(NSError *)error {
|
||
NSLog(@"rtcSession.error == %@",error);
|
||
[self.session end];
|
||
[self.view showToast:error.localizedDescription];
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||
[self.navigationController popViewControllerAnimated:true];
|
||
});
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onStats:(SellyRTCStats *)stats userId:(nullable NSString *)userId {
|
||
NSString *bitrate = [NSString stringWithFormat:@"%ld/%ld kbps", (NSInteger)stats.txKbps, (NSInteger)stats.rxKbps];
|
||
NSString *fps = [NSString stringWithFormat:@"%ld/%ld fps", (NSInteger)stats.sentFps, (NSInteger)stats.recvFps];
|
||
NSString *rtt = [NSString stringWithFormat:@"%ld ms", (NSInteger)stats.transportRttMs];
|
||
NSString *codec = [NSString stringWithFormat:@"%@/%@", stats.videoCodec, stats.audioCodec];
|
||
NSString *videoSize = [NSString stringWithFormat:@"%ldx%ld", stats.recvWidth, stats.recvHeight];
|
||
|
||
[self.statsView updateBitrate:bitrate];
|
||
[self.statsView updateVideoFps:fps];
|
||
[self.statsView updateRtt:rtt];
|
||
[self.statsView updateCodec:codec];
|
||
[self.statsView updateVideoSize:videoSize];
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onDuration:(NSInteger)duration {
|
||
NSString *durationStr = [NSString stringWithFormat:@"%02ld:%02ld", duration/60, duration%60];
|
||
[self.statsView updateDuration:durationStr];
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onUserJoined:(NSString *)userId {
|
||
NSLog(@"###onUserJoined == %@",userId);
|
||
|
||
// 标记远端用户已加入
|
||
self.remoteUserJoined = YES;
|
||
|
||
// 显示远端视图
|
||
self.remoteView.hidden = NO;
|
||
|
||
SellyRTCVideoCanvas *remoteCanvas = SellyRTCVideoCanvas.new;
|
||
remoteCanvas.view = self.remoteView;
|
||
remoteCanvas.userId = userId;
|
||
[self.session setRemoteCanvas:remoteCanvas];
|
||
|
||
[self QCM_stopRing];
|
||
|
||
// 通话接通后显示统计信息
|
||
[self.statsView show];
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session onUserLeave:(NSString *)userId {
|
||
NSLog(@"####onUserLeave == %@",userId);
|
||
|
||
// 隐藏远端视图
|
||
self.remoteView.hidden = YES;
|
||
self.remoteUserJoined = NO;
|
||
|
||
// 隐藏统计信息
|
||
[self.statsView hide];
|
||
|
||
[self.navigationController popViewControllerAnimated:true];
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session tokenWillExpire:(NSString *)token {
|
||
NSString *newToken = [TokenGenerator generateRTCCallTokenWithUserId:SellyCloudManager.sharedInstance.userId callId:self.channelId];
|
||
[session renewToken:newToken];
|
||
}
|
||
|
||
- (void)rtcSession:(SellyRTCSession *)session tokenExpired:(NSString *)token {
|
||
NSString *newToken = [TokenGenerator generateRTCCallTokenWithUserId:SellyCloudManager.sharedInstance.userId callId:self.channelId];
|
||
[session renewToken:newToken];
|
||
}
|
||
|
||
- (SellyRTCSession *)session {
|
||
if (!_session) {
|
||
_session = [[SellyRTCSession alloc] initWithType:true];
|
||
}
|
||
return _session;
|
||
}
|
||
|
||
- (void)dealloc
|
||
{
|
||
[self.session setAudioOutput:AVAudioSessionPortOverrideNone];
|
||
NSError *error;
|
||
[AVAudioSession.sharedInstance setCategory:AVAudioSessionCategoryPlayback error:&error];
|
||
}
|
||
|
||
#pragma mark - SellyCallControlViewDelegate
|
||
|
||
- (void)callControlView:(SellyCallControlView *)controlView didTapAction:(SellyCallControlAction)action {
|
||
switch (action) {
|
||
case SellyCallControlActionSpeaker:
|
||
[self onSpeakerClick:nil];
|
||
break;
|
||
|
||
case SellyCallControlActionVideo:
|
||
[self onCameraClick:nil];
|
||
break;
|
||
|
||
case SellyCallControlActionSwitchCamera:
|
||
[self onSwitchClick:nil];
|
||
break;
|
||
|
||
case SellyCallControlActionMute:
|
||
[self onMuteClick:nil];
|
||
break;
|
||
|
||
case SellyCallControlActionPiP:
|
||
[self onActionPIP:nil];
|
||
// 更新画中画按钮状态
|
||
if (@available(iOS 15.0, *)) {
|
||
[self.controlView updatePiPEnabled:self.pipManager.pipActive];
|
||
}
|
||
break;
|
||
|
||
case SellyCallControlActionHangup:
|
||
[self onHangupClick:nil];
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
@end
|