// // SellyVideoCallViewController.m // SellyCloudSDK_Example // // Created by Caleb on 3/11/25. // Copyright © 2025 Caleb. All rights reserved. // #import "SellyVideoCallViewController.h" #import #import "FUManager.h" #import "UIView+SellyCloud.h" #import "SellyCallPiPManager.h" #import "TokenGenerator.h" #import "SellyCallControlView.h" #import "SellyCallStatsView.h" #import @interface SellyVideoCallViewController () @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 == %ld",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