initial commit
This commit is contained in:
29
Example/SellyCloudSDK/VideoCall/SLSParticipant.h
Normal file
29
Example/SellyCloudSDK/VideoCall/SLSParticipant.h
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SLSParticipant.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SLSParticipant : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *uid;
|
||||
@property (nonatomic, copy) NSString *displayName;
|
||||
|
||||
/// 音量 0~1,用来做说话者高亮
|
||||
@property (nonatomic, assign) CGFloat level;
|
||||
|
||||
/// 音频 / 视频 是否静音
|
||||
@property (nonatomic, assign) BOOL audioMuted;
|
||||
@property (nonatomic, assign) BOOL videoMuted;
|
||||
|
||||
/// 上次活跃时间(可选,用于后面做发言排序等)
|
||||
@property (nonatomic, strong) NSDate *lastActiveAt;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
13
Example/SellyCloudSDK/VideoCall/SLSParticipant.m
Normal file
13
Example/SellyCloudSDK/VideoCall/SLSParticipant.m
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// SLSParticipant.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SLSParticipant.h"
|
||||
|
||||
@implementation SLSParticipant
|
||||
|
||||
@end
|
||||
38
Example/SellyCloudSDK/VideoCall/SLSVideoGridView.h
Normal file
38
Example/SellyCloudSDK/VideoCall/SLSVideoGridView.h
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// SLSVideoGridView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "SLSParticipant.h"
|
||||
#import "SLSVideoTileView.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SLSVideoGridView : UIView
|
||||
|
||||
@property (nonatomic, assign) CGFloat spacing; // 默认 8
|
||||
@property (nonatomic, assign) UIEdgeInsets padding;// 默认 {12,12,12,12}
|
||||
@property (nonatomic, assign) BOOL keepAspect169; // 默认 YES
|
||||
|
||||
/// 创建或获取某个 uid 的 tile,并返回其中的 contentView 作为渲染容器
|
||||
- (SLSVideoTileView *)ensureRenderContainerForUID:(NSString *)uid
|
||||
displayName:(nullable NSString *)name;
|
||||
|
||||
/// 移除某个 uid(用户离开)
|
||||
- (void)detachUID:(NSString *)uid;
|
||||
|
||||
/// 更新音量(0~1),用于说话者高亮
|
||||
- (void)setLevel:(CGFloat)level forUID:(NSString *)uid;
|
||||
|
||||
/// 更新静音状态
|
||||
- (void)setAudioMuted:(BOOL)muted forUID:(NSString *)uid;
|
||||
- (void)setVideoMuted:(BOOL)muted forUID:(NSString *)uid;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
180
Example/SellyCloudSDK/VideoCall/SLSVideoGridView.m
Normal file
180
Example/SellyCloudSDK/VideoCall/SLSVideoGridView.m
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// SLSVideoGridView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SLSVideoGridView.h"
|
||||
|
||||
@interface SLSVideoGridView ()
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString*, SLSParticipant*> *participants;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString*, SLSVideoTileView*> *tiles;
|
||||
@end
|
||||
|
||||
@implementation SLSVideoGridView
|
||||
|
||||
// 代码创建时走这里
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
[self commonInit];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// xib / storyboard 反序列化时走这里
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
if (self = [super initWithCoder:coder]) {
|
||||
[self commonInit];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// 公共初始化逻辑
|
||||
- (void)commonInit {
|
||||
_participants = [NSMutableDictionary dictionary];
|
||||
_tiles = [NSMutableDictionary dictionary];
|
||||
_spacing = 8.f;
|
||||
_padding = UIEdgeInsetsMake(8, 8, 8, 8);
|
||||
_keepAspect169 = YES;
|
||||
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (SLSVideoTileView *)ensureRenderContainerForUID:(NSString *)uid
|
||||
displayName:(nullable NSString *)name {
|
||||
if (!uid) return nil;
|
||||
|
||||
SLSParticipant *p = self.participants[uid];
|
||||
if (!p) {
|
||||
p = [SLSParticipant new];
|
||||
p.uid = uid;
|
||||
p.displayName = name ?: uid;
|
||||
p.lastActiveAt = [NSDate date];
|
||||
self.participants[uid] = p;
|
||||
}
|
||||
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
if (!tile) {
|
||||
tile = [[SLSVideoTileView alloc] initWithFrame:CGRectZero];
|
||||
self.tiles[uid] = tile;
|
||||
[self addSubview:tile];
|
||||
}
|
||||
|
||||
[tile updateWithParticipant:p];
|
||||
[self _layout];
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
- (void)detachUID:(NSString *)uid {
|
||||
if (!uid) return;
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
if (tile) {
|
||||
[tile removeFromSuperview];
|
||||
[self.tiles removeObjectForKey:uid];
|
||||
}
|
||||
[self.participants removeObjectForKey:uid];
|
||||
[self _layout];
|
||||
}
|
||||
|
||||
- (void)setLevel:(CGFloat)level forUID:(NSString *)uid {
|
||||
SLSParticipant *p = self.participants[uid];
|
||||
if (!p) return;
|
||||
p.level = MAX(0.f, MIN(level, 1.f));
|
||||
if (p.level > 0.1) {
|
||||
p.lastActiveAt = [NSDate date];
|
||||
}
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
[tile updateWithParticipant:p];
|
||||
}
|
||||
|
||||
- (void)setAudioMuted:(BOOL)muted forUID:(NSString *)uid {
|
||||
SLSParticipant *p = self.participants[uid];
|
||||
if (!p) return;
|
||||
p.audioMuted = muted;
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
[tile updateWithParticipant:p];
|
||||
}
|
||||
|
||||
- (void)setVideoMuted:(BOOL)muted forUID:(NSString *)uid {
|
||||
SLSParticipant *p = self.participants[uid];
|
||||
if (!p) return;
|
||||
p.videoMuted = muted;
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
[tile updateWithParticipant:p];
|
||||
}
|
||||
|
||||
#pragma mark - Layout
|
||||
|
||||
- (void)_layout {
|
||||
NSArray<NSString *> *uids = self.participants.allKeys;
|
||||
NSInteger n = uids.count;
|
||||
if (n == 0) return;
|
||||
|
||||
UIEdgeInsets p = self.padding;
|
||||
CGFloat W = self.bounds.size.width - p.left - p.right;
|
||||
CGFloat H = self.bounds.size.height - p.top - p.bottom;
|
||||
CGFloat s = self.spacing;
|
||||
|
||||
// ===== 按人数选择固定的行列数 =====
|
||||
NSInteger cols = 1;
|
||||
NSInteger rows = 1;
|
||||
|
||||
if (n <= 4) {
|
||||
cols = 2;
|
||||
rows = 2;
|
||||
} else if (n <= 9) {
|
||||
cols = 3;
|
||||
rows = 3;
|
||||
} else if (n <= 16) {
|
||||
cols = 4;
|
||||
rows = 4;
|
||||
} else {
|
||||
// >16 的情况先简单处理:依旧按 4x4 布局,多出来的不显示
|
||||
cols = 4;
|
||||
rows = 4;
|
||||
}
|
||||
|
||||
// 每个 cell 的宽 / 高(都按“可用区域的 1/2、1/3、1/4”来,扣掉间距)
|
||||
CGFloat cellW = 0;
|
||||
CGFloat cellH = 0;
|
||||
if (cols > 0) {
|
||||
cellW = floor((W - s * (cols - 1)) / cols);
|
||||
}
|
||||
if (rows > 0) {
|
||||
cellH = floor((H - s * (rows - 1)) / rows);
|
||||
}
|
||||
|
||||
NSInteger maxCells = cols * rows;
|
||||
NSInteger countToLayout = MIN(n, maxCells);
|
||||
|
||||
[uids enumerateObjectsUsingBlock:^(NSString *uid, NSUInteger idx, BOOL *stop) {
|
||||
if (idx >= countToLayout) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
NSInteger r = idx / cols; // 第几行
|
||||
NSInteger c = idx % cols; // 第几列
|
||||
|
||||
CGFloat x = p.left + c * (cellW + s);
|
||||
CGFloat y = p.top + r * (cellH + s);
|
||||
|
||||
SLSVideoTileView *tile = self.tiles[uid];
|
||||
tile.frame = CGRectMake(x, y, cellW, cellH);
|
||||
|
||||
SLSParticipant *part = self.participants[uid];
|
||||
[tile updateWithParticipant:part];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
[self _layout];
|
||||
}
|
||||
|
||||
@end
|
||||
27
Example/SellyCloudSDK/VideoCall/SLSVideoTileView.h
Normal file
27
Example/SellyCloudSDK/VideoCall/SLSVideoTileView.h
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SLSVideoTileView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import "SLSParticipant.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SLSVideoTileView : UIView
|
||||
|
||||
/// 容器视图:SDK 的渲染 view 挂在这里
|
||||
@property (nonatomic, strong, readonly) UIView *contentView;
|
||||
|
||||
/// 根据参会者状态更新 UI(昵称、静音、视频开关、高亮等)
|
||||
- (void)updateWithParticipant:(SLSParticipant *)participant;
|
||||
|
||||
@property (nonatomic, strong)SellyRTCStats *stats;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
194
Example/SellyCloudSDK/VideoCall/SLSVideoTileView.m
Normal file
194
Example/SellyCloudSDK/VideoCall/SLSVideoTileView.m
Normal file
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// SLSVideoTileView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 13/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SLSVideoTileView.h"
|
||||
|
||||
@interface SLSVideoTileView ()
|
||||
@property (nonatomic, strong)NSString *userId;
|
||||
|
||||
@property (nonatomic, strong) UIView *contentViewInternal;
|
||||
@property (nonatomic, strong) UILabel *nameLabel;
|
||||
@property (nonatomic, strong) UIImageView *muteIcon;
|
||||
@property (nonatomic, strong) UIView *videoMaskView;
|
||||
@property (nonatomic, strong) UILabel *videoOffLabel;
|
||||
@property (nonatomic, strong) UILabel *bitrate;
|
||||
@property (nonatomic, strong) UILabel *fps;
|
||||
@property (nonatomic, strong) UILabel *codec;
|
||||
@property (nonatomic, strong) UILabel *rtt;
|
||||
@end
|
||||
|
||||
@implementation SLSVideoTileView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.layer.cornerRadius = 10.f;
|
||||
self.clipsToBounds = YES;
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
|
||||
_contentViewInternal = [[UIView alloc] initWithFrame:self.bounds];
|
||||
_contentViewInternal.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
_contentViewInternal.backgroundColor = [UIColor blackColor];
|
||||
[self addSubview:_contentViewInternal];
|
||||
|
||||
_videoMaskView = [[UIView alloc] initWithFrame:self.bounds];
|
||||
_videoMaskView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
_videoMaskView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.6];
|
||||
_videoMaskView.hidden = YES;
|
||||
[self addSubview:_videoMaskView];
|
||||
|
||||
_videoOffLabel = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
_videoOffLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_videoOffLabel.textColor = [UIColor lightGrayColor];
|
||||
_videoOffLabel.font = [UIFont systemFontOfSize:14];
|
||||
_videoOffLabel.text = @"视频已关闭";
|
||||
[_videoMaskView addSubview:_videoOffLabel];
|
||||
|
||||
_nameLabel = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
_nameLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightMedium];
|
||||
_nameLabel.textColor = [UIColor whiteColor];
|
||||
_nameLabel.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
||||
_nameLabel.layer.cornerRadius = 8;
|
||||
_nameLabel.clipsToBounds = YES;
|
||||
[self addSubview:_nameLabel];
|
||||
|
||||
_muteIcon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"mic.slash.fill"]];
|
||||
_muteIcon.tintColor = [UIColor systemRedColor];
|
||||
[self addSubview:_muteIcon];
|
||||
|
||||
[self addSubview:self.bitrate];
|
||||
[self addSubview:self.fps];
|
||||
[self addSubview:self.codec];
|
||||
[self addSubview:self.rtt];
|
||||
|
||||
[self.bitrate mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.top.offset(10);
|
||||
}];
|
||||
|
||||
[self.fps mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.bitrate.mas_bottom).offset(10);
|
||||
make.left.offset(10);
|
||||
}];
|
||||
|
||||
[self.codec mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.fps.mas_bottom).offset(10);
|
||||
make.left.offset(10);
|
||||
}];
|
||||
|
||||
[self.rtt mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.codec.mas_bottom).offset(10);
|
||||
make.left.offset(10);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
return self.contentViewInternal;
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
self.contentViewInternal.frame = self.bounds;
|
||||
self.videoMaskView.frame = self.bounds;
|
||||
self.videoOffLabel.frame = CGRectInset(self.videoMaskView.bounds, 8, 8);
|
||||
|
||||
CGFloat padding = 6.f;
|
||||
CGSize maxSize = CGSizeMake(self.bounds.size.width - 2*padding, 20);
|
||||
CGSize nameSize = [self.nameLabel sizeThatFits:maxSize];
|
||||
self.nameLabel.frame = CGRectMake(padding,
|
||||
self.bounds.size.height - nameSize.height - padding,
|
||||
nameSize.width + 12,
|
||||
nameSize.height);
|
||||
|
||||
self.muteIcon.frame = CGRectMake(CGRectGetMaxX(self.nameLabel.frame) + 4,
|
||||
CGRectGetMinY(self.nameLabel.frame) - 2,
|
||||
18,
|
||||
18);
|
||||
}
|
||||
|
||||
- (void)updateWithParticipant:(SLSParticipant *)p {
|
||||
self.userId = p.uid;
|
||||
// 昵称
|
||||
self.nameLabel.text = p.displayName.length ? p.displayName : p.uid;
|
||||
|
||||
// 音频静音图标
|
||||
self.muteIcon.hidden = !p.audioMuted;
|
||||
|
||||
// 视频开关:只用遮罩层控制,不移除 contentView 里的子视图
|
||||
BOOL videoOn = !p.videoMuted;
|
||||
self.videoMaskView.hidden = videoOn;
|
||||
|
||||
// 说话者高亮
|
||||
if (p.level > 0.25 && !p.audioMuted) {
|
||||
self.layer.borderWidth = 2.f;
|
||||
self.layer.borderColor = [UIColor systemGreenColor].CGColor;
|
||||
} else {
|
||||
self.layer.borderWidth = 0.f;
|
||||
self.layer.borderColor = nil;
|
||||
}
|
||||
|
||||
[self setNeedsLayout];
|
||||
}
|
||||
|
||||
- (void)setStats:(SellyRTCStats *)stats {
|
||||
_stats = stats;
|
||||
|
||||
|
||||
self.rtt.text = [NSString stringWithFormat:@" rtt:%ldms ",(NSInteger)stats.transportRttMs];
|
||||
self.codec.text = [NSString stringWithFormat:@" %@/%@ ",stats.videoCodec,stats.audioCodec];
|
||||
if ([self.userId isEqualToString:SellyCloudManager.sharedInstance.userId]) {
|
||||
self.bitrate.text = [NSString stringWithFormat:@" 码率:%ld kbps ",(NSInteger)(stats.txKbps)];
|
||||
self.fps.text = [NSString stringWithFormat:@" 视频:%ld fps %ldx%ld ",(NSInteger)(stats.sentFps),(NSInteger)(stats.sentWidth),(NSInteger)(stats.sentHeight)];
|
||||
|
||||
}
|
||||
else {
|
||||
self.bitrate.text = [NSString stringWithFormat:@" 码率:%ld kbps ",(NSInteger)(stats.rxKbps)];
|
||||
self.fps.text = [NSString stringWithFormat:@" 视频:%ld fps %ldx%ld ",(NSInteger)(stats.recvFps),(NSInteger)(stats.recvWidth),(NSInteger)(stats.recvHeight)];
|
||||
}
|
||||
}
|
||||
|
||||
- (UILabel *)bitrate {
|
||||
if (!_bitrate) {
|
||||
_bitrate = [self createStatsLabel];
|
||||
}
|
||||
return _bitrate;
|
||||
}
|
||||
|
||||
- (UILabel *)fps {
|
||||
if (!_fps) {
|
||||
_fps = [self createStatsLabel];
|
||||
}
|
||||
return _fps;
|
||||
}
|
||||
|
||||
- (UILabel *)codec {
|
||||
if (!_codec) {
|
||||
_codec = [self createStatsLabel];
|
||||
}
|
||||
return _codec;
|
||||
}
|
||||
|
||||
- (UILabel *)rtt {
|
||||
if (!_rtt) {
|
||||
_rtt = [self createStatsLabel];
|
||||
}
|
||||
return _rtt;
|
||||
}
|
||||
|
||||
- (UILabel *)createStatsLabel {
|
||||
UILabel *label = [[UILabel alloc] init];
|
||||
label.font = [UIFont systemFontOfSize:12];
|
||||
label.textColor = [UIColor whiteColor];
|
||||
label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.2];
|
||||
label.textAlignment = NSTextAlignmentLeft;
|
||||
label.layer.cornerRadius = 4;
|
||||
label.layer.masksToBounds = YES;
|
||||
return label;
|
||||
}
|
||||
|
||||
@end
|
||||
48
Example/SellyCloudSDK/VideoCall/SellyCallControlView.h
Normal file
48
Example/SellyCloudSDK/VideoCall/SellyCallControlView.h
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// SellyCallControlView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 12/17/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSUInteger, SellyCallControlAction) {
|
||||
SellyCallControlActionSpeaker,
|
||||
SellyCallControlActionVideo,
|
||||
SellyCallControlActionSwitchCamera,
|
||||
SellyCallControlActionMute,
|
||||
SellyCallControlActionPiP,
|
||||
SellyCallControlActionScreenShare,
|
||||
SellyCallControlActionHangup
|
||||
};
|
||||
|
||||
@class SellyCallControlView;
|
||||
|
||||
@protocol SellyCallControlViewDelegate <NSObject>
|
||||
|
||||
- (void)callControlView:(SellyCallControlView *)controlView didTapAction:(SellyCallControlAction)action;
|
||||
|
||||
@end
|
||||
|
||||
@interface SellyCallControlView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<SellyCallControlViewDelegate> delegate;
|
||||
|
||||
// 控制按钮显示/隐藏
|
||||
@property (nonatomic, assign) BOOL showPiPButton; // 是否显示画中画按钮(默认 NO)
|
||||
@property (nonatomic, assign) BOOL showScreenShareButton; // 是否显示屏幕分享按钮(默认 NO)
|
||||
|
||||
// 更新按钮状态
|
||||
- (void)updateSpeakerEnabled:(BOOL)enabled;
|
||||
- (void)updateVideoEnabled:(BOOL)enabled;
|
||||
- (void)updateMuteEnabled:(BOOL)muted;
|
||||
- (void)updatePiPEnabled:(BOOL)enabled; // 画中画状态
|
||||
- (void)updateScreenShareEnabled:(BOOL)enabled; // 屏幕分享状态
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
358
Example/SellyCloudSDK/VideoCall/SellyCallControlView.m
Normal file
358
Example/SellyCloudSDK/VideoCall/SellyCallControlView.m
Normal file
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// SellyCallControlView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 12/17/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SellyCallControlView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface SellyCallControlView ()
|
||||
|
||||
@property (nonatomic, strong) UIButton *speakerButton;
|
||||
@property (nonatomic, strong) UIButton *videoButton;
|
||||
@property (nonatomic, strong) UIButton *switchCameraButton;
|
||||
@property (nonatomic, strong) UIButton *muteButton;
|
||||
@property (nonatomic, strong) UIButton *pipButton;
|
||||
@property (nonatomic, strong) UIButton *screenShareButton;
|
||||
@property (nonatomic, strong) UIButton *hangupButton;
|
||||
|
||||
@property (nonatomic, strong) UIStackView *mainStackView; // 主垂直容器
|
||||
@property (nonatomic, strong) UIStackView *topButtonStackView; // 上排按钮
|
||||
@property (nonatomic, strong) UIStackView *bottomButtonStackView; // 下排按钮
|
||||
|
||||
@end
|
||||
|
||||
@implementation SellyCallControlView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
// 设置透明背景
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
// 创建主垂直容器
|
||||
self.mainStackView = [[UIStackView alloc] init];
|
||||
self.mainStackView.axis = UILayoutConstraintAxisVertical;
|
||||
self.mainStackView.distribution = UIStackViewDistributionFillEqually;
|
||||
self.mainStackView.alignment = UIStackViewAlignmentFill;
|
||||
self.mainStackView.spacing = 10;
|
||||
[self addSubview:self.mainStackView];
|
||||
|
||||
// 创建上排按钮容器
|
||||
self.topButtonStackView = [[UIStackView alloc] init];
|
||||
self.topButtonStackView.axis = UILayoutConstraintAxisHorizontal;
|
||||
self.topButtonStackView.distribution = UIStackViewDistributionEqualSpacing;
|
||||
self.topButtonStackView.alignment = UIStackViewAlignmentCenter;
|
||||
self.topButtonStackView.spacing = 0;
|
||||
|
||||
// 创建下排按钮容器
|
||||
self.bottomButtonStackView = [[UIStackView alloc] init];
|
||||
self.bottomButtonStackView.axis = UILayoutConstraintAxisHorizontal;
|
||||
self.bottomButtonStackView.distribution = UIStackViewDistributionEqualSpacing;
|
||||
self.bottomButtonStackView.alignment = UIStackViewAlignmentCenter;
|
||||
self.bottomButtonStackView.spacing = 0;
|
||||
|
||||
[self.mainStackView addArrangedSubview:self.topButtonStackView];
|
||||
[self.mainStackView addArrangedSubview:self.bottomButtonStackView];
|
||||
|
||||
// 创建按钮
|
||||
self.speakerButton = [self createButtonWithImageName:@"speaker" title:@"扬声器" action:@selector(speakerButtonTapped:)];
|
||||
self.videoButton = [self createButtonWithImageName:@"video" title:@"视频" action:@selector(videoButtonTapped:)];
|
||||
self.switchCameraButton = [self createButtonWithImageName:@"switch_camera" title:@"切换" action:@selector(switchCameraButtonTapped:)];
|
||||
self.muteButton = [self createButtonWithImageName:@"microphone" title:@"静音" action:@selector(muteButtonTapped:)];
|
||||
self.pipButton = [self createButtonWithImageName:@"pip" title:@"画中画" action:@selector(pipButtonTapped:)];
|
||||
self.screenShareButton = [self createButtonWithImageName:@"screen_share" title:@"共享屏幕" action:@selector(screenShareButtonTapped:)];
|
||||
self.hangupButton = [self createButtonWithImageName:@"hangup" title:@"挂断" action:@selector(hangupButtonTapped:)];
|
||||
|
||||
// 挂断按钮的图标背景设置为红色
|
||||
UIView *hangupIconBackground = [self.hangupButton viewWithTag:999];
|
||||
if (hangupIconBackground) {
|
||||
hangupIconBackground.backgroundColor = [UIColor colorWithRed:0.9 green:0.2 blue:0.2 alpha:1.0];
|
||||
}
|
||||
|
||||
// 添加按钮到上排容器(4个按钮)
|
||||
[self.topButtonStackView addArrangedSubview:self.speakerButton];
|
||||
[self.topButtonStackView addArrangedSubview:self.videoButton];
|
||||
[self.topButtonStackView addArrangedSubview:self.switchCameraButton];
|
||||
[self.topButtonStackView addArrangedSubview:self.muteButton];
|
||||
|
||||
// 添加按钮到下排容器(根据配置显示 PiP 或屏幕分享)
|
||||
// 默认不显示 PiP 和屏幕分享按钮,由外部控制
|
||||
self.pipButton.hidden = YES;
|
||||
self.screenShareButton.hidden = YES;
|
||||
[self.bottomButtonStackView addArrangedSubview:self.pipButton];
|
||||
[self.bottomButtonStackView addArrangedSubview:self.screenShareButton];
|
||||
[self.bottomButtonStackView addArrangedSubview:self.hangupButton];
|
||||
|
||||
// 布局约束 - 使用 Masonry
|
||||
[self.mainStackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.leading.equalTo(self).offset(15);
|
||||
make.trailing.equalTo(self).offset(-15);
|
||||
make.top.equalTo(self).offset(10);
|
||||
make.bottom.equalTo(self).offset(-10);
|
||||
}];
|
||||
|
||||
// 上排容器内边距(让按钮居中分布)
|
||||
[self.topButtonStackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.leading.trailing.equalTo(self.mainStackView);
|
||||
}];
|
||||
|
||||
// 下排容器内边距(让2个按钮合理分布,不要太分散)
|
||||
[self.bottomButtonStackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.mainStackView);
|
||||
make.width.mas_lessThanOrEqualTo(self.mainStackView);
|
||||
}];
|
||||
}
|
||||
|
||||
- (UIButton *)createButtonWithImageName:(NSString *)imageName title:(NSString *)title action:(SEL)action {
|
||||
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
|
||||
// 创建容器视图
|
||||
UIView *containerView = [[UIView alloc] init];
|
||||
containerView.userInteractionEnabled = NO;
|
||||
[button addSubview:containerView];
|
||||
|
||||
// 图标背景
|
||||
UIView *iconBackground = [[UIView alloc] init];
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
iconBackground.layer.cornerRadius = 26; // 52/2 = 26
|
||||
iconBackground.clipsToBounds = YES;
|
||||
iconBackground.userInteractionEnabled = NO;
|
||||
iconBackground.tag = 999; // 添加tag以便后续识别
|
||||
[containerView addSubview:iconBackground];
|
||||
|
||||
// 使用系统图标
|
||||
UIImage *icon = [self systemImageForButtonName:imageName];
|
||||
UIImageView *imageView = [[UIImageView alloc] initWithImage:icon];
|
||||
imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
imageView.tintColor = [UIColor whiteColor];
|
||||
imageView.userInteractionEnabled = NO;
|
||||
[iconBackground addSubview:imageView];
|
||||
|
||||
// 标题标签
|
||||
UILabel *titleLabel = [[UILabel alloc] init];
|
||||
titleLabel.text = title;
|
||||
titleLabel.textColor = [UIColor whiteColor];
|
||||
titleLabel.font = [UIFont systemFontOfSize:12];
|
||||
titleLabel.textAlignment = NSTextAlignmentCenter;
|
||||
titleLabel.userInteractionEnabled = NO;
|
||||
[containerView addSubview:titleLabel];
|
||||
|
||||
// 使用 Masonry 布局
|
||||
[containerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(button);
|
||||
make.width.mas_equalTo(70);
|
||||
make.height.mas_equalTo(90);
|
||||
}];
|
||||
|
||||
[iconBackground mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(containerView);
|
||||
make.top.equalTo(containerView);
|
||||
make.width.height.mas_equalTo(52);
|
||||
}];
|
||||
|
||||
[imageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(iconBackground);
|
||||
make.width.height.mas_equalTo(28);
|
||||
}];
|
||||
|
||||
[titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(iconBackground.mas_bottom).offset(8);
|
||||
make.centerX.equalTo(containerView);
|
||||
make.leading.trailing.equalTo(containerView);
|
||||
}];
|
||||
|
||||
[button addTarget:self action:action forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
// 设置按钮尺寸
|
||||
[button mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(70);
|
||||
make.height.mas_equalTo(90);
|
||||
}];
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
- (UIImage *)systemImageForButtonName:(NSString *)name {
|
||||
NSString *systemName = @"questionmark.circle";
|
||||
|
||||
if ([name isEqualToString:@"speaker"]) {
|
||||
systemName = @"speaker.wave.2.fill";
|
||||
} else if ([name isEqualToString:@"video"]) {
|
||||
systemName = @"video.fill";
|
||||
} else if ([name isEqualToString:@"switch_camera"]) {
|
||||
systemName = @"arrow.triangle.2.circlepath.camera.fill";
|
||||
} else if ([name isEqualToString:@"microphone"]) {
|
||||
systemName = @"mic.fill";
|
||||
} else if ([name isEqualToString:@"pip"]) {
|
||||
systemName = @"pip.fill";
|
||||
} else if ([name isEqualToString:@"screen_share"]) {
|
||||
systemName = @"rectangle.on.rectangle";
|
||||
} else if ([name isEqualToString:@"hangup"]) {
|
||||
systemName = @"phone.down.fill";
|
||||
}
|
||||
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
return [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
|
||||
#pragma mark - Button Actions
|
||||
|
||||
- (void)speakerButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionSpeaker];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)videoButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionVideo];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)switchCameraButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionSwitchCamera];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)muteButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionMute];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)hangupButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionHangup];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)pipButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionPiP];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)screenShareButtonTapped:(UIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(callControlView:didTapAction:)]) {
|
||||
[self.delegate callControlView:self didTapAction:SellyCallControlActionScreenShare];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)setShowPiPButton:(BOOL)showPiPButton {
|
||||
_showPiPButton = showPiPButton;
|
||||
self.pipButton.hidden = !showPiPButton;
|
||||
}
|
||||
|
||||
- (void)setShowScreenShareButton:(BOOL)showScreenShareButton {
|
||||
_showScreenShareButton = showScreenShareButton;
|
||||
self.screenShareButton.hidden = !showScreenShareButton;
|
||||
}
|
||||
|
||||
- (void)updateSpeakerEnabled:(BOOL)enabled {
|
||||
// 更新图标和背景
|
||||
UIView *iconBackground = self.speakerButton.subviews.firstObject.subviews.firstObject;
|
||||
if (enabled) {
|
||||
iconBackground.backgroundColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:1.0];
|
||||
NSString *systemName = @"speaker.wave.2.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
} else {
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
NSString *systemName = @"speaker.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateVideoEnabled:(BOOL)enabled {
|
||||
UIView *iconBackground = self.videoButton.subviews.firstObject.subviews.firstObject;
|
||||
if (enabled) {
|
||||
iconBackground.backgroundColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:1.0];
|
||||
NSString *systemName = @"video.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
} else {
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
NSString *systemName = @"video.slash.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateMuteEnabled:(BOOL)muted {
|
||||
UIView *iconBackground = self.muteButton.subviews.firstObject.subviews.firstObject;
|
||||
if (muted) {
|
||||
iconBackground.backgroundColor = [UIColor colorWithRed:0.9 green:0.2 blue:0.2 alpha:1.0];
|
||||
NSString *systemName = @"mic.slash.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
} else {
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
NSString *systemName = @"mic.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updatePiPEnabled:(BOOL)enabled {
|
||||
UIView *iconBackground = self.pipButton.subviews.firstObject.subviews.firstObject;
|
||||
if (enabled) {
|
||||
iconBackground.backgroundColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:1.0];
|
||||
NSString *systemName = @"pip.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
} else {
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
NSString *systemName = @"pip";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateScreenShareEnabled:(BOOL)enabled {
|
||||
UIView *iconBackground = self.screenShareButton.subviews.firstObject.subviews.firstObject;
|
||||
if (enabled) {
|
||||
iconBackground.backgroundColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:1.0];
|
||||
NSString *systemName = @"rectangle.on.rectangle.fill";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
} else {
|
||||
iconBackground.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.2];
|
||||
NSString *systemName = @"rectangle.on.rectangle";
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:28 weight:UIImageSymbolWeightMedium scale:UIImageSymbolScaleMedium];
|
||||
UIImageView *imageView = (UIImageView *)iconBackground.subviews.firstObject;
|
||||
imageView.image = [UIImage systemImageNamed:systemName withConfiguration:config];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
45
Example/SellyCloudSDK/VideoCall/SellyCallPiPManager.h
Normal file
45
Example/SellyCloudSDK/VideoCall/SellyCallPiPManager.h
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// SellyCallPiPManager.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 19/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AVKit/AVKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 负责:
|
||||
/// - 初始化 AVSampleBufferDisplayLayer
|
||||
/// - 管理 AVPictureInPictureController
|
||||
/// - 接收业务层传入的 SellyRTCVideoFrame 并送入 PiP
|
||||
@interface SellyCallPiPManager : NSObject
|
||||
<AVPictureInPictureControllerDelegate, AVPictureInPictureSampleBufferPlaybackDelegate>
|
||||
|
||||
/// 是否当前设备/系统支持自定义 PiP
|
||||
@property (nonatomic, assign, readonly) BOOL pipPossible;
|
||||
|
||||
/// 方便外界知道当前是否在 PiP
|
||||
@property (nonatomic, assign, readonly) BOOL pipActive;
|
||||
|
||||
/// 指定在哪个 view 里显示(App 内)这条 PiP 的画面(可选)
|
||||
- (instancetype)initWithRenderView:(UIView *)renderView;
|
||||
|
||||
/// 初始化 / 配置 PiP,通常在 viewDidLoad 调一次
|
||||
- (void)setupIfNeeded;
|
||||
|
||||
/// 外界点按钮时调用:开启 / 关闭 PiP
|
||||
- (void)togglePiP;
|
||||
|
||||
/// 外界在收到远端视频帧回调时调用
|
||||
- (void)feedVideoFrame:(SellyRTCVideoFrame *)frame;
|
||||
|
||||
/// 彻底销毁 PiP,释放 controller 和 layer,页面退出时必须调用
|
||||
- (void)invalidate;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
265
Example/SellyCloudSDK/VideoCall/SellyCallPiPManager.m
Normal file
265
Example/SellyCloudSDK/VideoCall/SellyCallPiPManager.m
Normal file
@@ -0,0 +1,265 @@
|
||||
//
|
||||
// SellyCallPiPManager.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 19/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SellyCallPiPManager.h"
|
||||
#import <AVKit/AVKit.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
@interface SellyCallPiPManager ()
|
||||
|
||||
@property (nonatomic, weak) UIView *renderView;
|
||||
|
||||
@property (nonatomic, strong) AVSampleBufferDisplayLayer *pipSampleBufferLayer;
|
||||
@property (nonatomic, strong) AVPictureInPictureController *pipController;
|
||||
@property (nonatomic, strong) dispatch_queue_t pipQueue;
|
||||
@property (nonatomic, assign, readwrite) BOOL pipPossible;
|
||||
@property (nonatomic, assign, readwrite) BOOL pipActive;
|
||||
|
||||
@end
|
||||
|
||||
@implementation SellyCallPiPManager
|
||||
|
||||
- (instancetype)initWithRenderView:(UIView *)renderView {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_renderView = renderView;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupIfNeeded {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (![AVPictureInPictureController isPictureInPictureSupported]) {
|
||||
self.pipPossible = NO;
|
||||
return;
|
||||
}
|
||||
|
||||
self.pipQueue = dispatch_queue_create("com.sellycloud.pip.queue", DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
// SampleBuffer layer(你可以决定要不要加到 renderView 上显示)
|
||||
self.pipSampleBufferLayer = [[AVSampleBufferDisplayLayer alloc] init];
|
||||
self.pipSampleBufferLayer.videoGravity = AVLayerVideoGravityResizeAspect;
|
||||
|
||||
if (self.renderView) {
|
||||
self.pipSampleBufferLayer.frame = self.renderView.bounds;
|
||||
[self.renderView.layer addSublayer:self.pipSampleBufferLayer];
|
||||
}
|
||||
|
||||
// PiP content source
|
||||
AVPictureInPictureControllerContentSource *source =
|
||||
[[AVPictureInPictureControllerContentSource alloc] initWithSampleBufferDisplayLayer:self.pipSampleBufferLayer
|
||||
playbackDelegate:self];
|
||||
|
||||
self.pipController = [[AVPictureInPictureController alloc] initWithContentSource:source];
|
||||
self.pipController.delegate = self;
|
||||
self.pipController.canStartPictureInPictureAutomaticallyFromInline = true;
|
||||
self.pipController.requiresLinearPlayback = true;
|
||||
|
||||
self.pipPossible = (self.pipController != nil);
|
||||
} else {
|
||||
self.pipPossible = NO;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)togglePiP {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
NSLog(@"[PiP] togglePiP called");
|
||||
NSLog(@"[PiP] pipController: %@", self.pipController ? @"存在" : @"nil");
|
||||
NSLog(@"[PiP] isPictureInPicturePossible: %d", self.pipController.isPictureInPicturePossible);
|
||||
NSLog(@"[PiP] isPictureInPictureActive: %d", self.pipController.isPictureInPictureActive);
|
||||
NSLog(@"[PiP] isPictureInPictureSupported: %d", [AVPictureInPictureController isPictureInPictureSupported]);
|
||||
NSLog(@"[PiP] sampleBufferLayer status: %ld", (long)self.pipSampleBufferLayer.status);
|
||||
|
||||
if (!self.pipController) {
|
||||
NSLog(@"[PiP] ❌ pipController is nil");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.pipController.isPictureInPicturePossible) {
|
||||
NSLog(@"[PiP] ❌ isPictureInPicturePossible is NO");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.pipController.isPictureInPictureActive) {
|
||||
// 🔧 先不停止喂数据,让 layer 保持有内容
|
||||
NSLog(@"[PiP] ✅ 开始启动画中画...");
|
||||
[self.pipController startPictureInPicture];
|
||||
} else {
|
||||
NSLog(@"[PiP] ✅ 停止画中画...");
|
||||
[self.pipController stopPictureInPicture];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Feed frame
|
||||
|
||||
- (void)feedVideoFrame:(SellyRTCVideoFrame *)frame {
|
||||
if (!self.pipSampleBufferLayer || !frame.pixelBuffer) return;
|
||||
if (!self.pipPossible) return;
|
||||
|
||||
// 🔧 不再阻止喂数据,让 PiP 可以持续显示画面
|
||||
|
||||
CMSampleBufferRef sb = [self createSampleBufferFromVideoFrame:frame];
|
||||
if (!sb) return;
|
||||
|
||||
dispatch_async(self.pipQueue, ^{
|
||||
if (self.pipSampleBufferLayer.status == AVQueuedSampleBufferRenderingStatusFailed) {
|
||||
NSLog(@"[PiP] display layer failed: %@",
|
||||
self.pipSampleBufferLayer.error);
|
||||
[self.pipSampleBufferLayer flush];
|
||||
}
|
||||
[self.pipSampleBufferLayer enqueueSampleBuffer:sb];
|
||||
CFRelease(sb);
|
||||
});
|
||||
}
|
||||
|
||||
- (CMSampleBufferRef)createSampleBufferFromVideoFrame:(SellyRTCVideoFrame *)videoData {
|
||||
if (!videoData || !videoData.pixelBuffer) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
CVPixelBufferRef pixelBuffer = videoData.pixelBuffer;
|
||||
|
||||
CMVideoFormatDescriptionRef videoInfo = NULL;
|
||||
OSStatus status = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault,
|
||||
pixelBuffer,
|
||||
&videoInfo);
|
||||
if (status != noErr || !videoInfo) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
CMTime pts;
|
||||
if (videoData.timestamp > 0) {
|
||||
// 假设 timestamp 是 ns
|
||||
pts = CMTimeMake(videoData.timestamp, 1000000000);
|
||||
} else {
|
||||
CFTimeInterval t = CACurrentMediaTime();
|
||||
int64_t ms = (int64_t)(t * 1000);
|
||||
pts = CMTimeMake(ms, 1000);
|
||||
}
|
||||
|
||||
CMSampleTimingInfo timingInfo;
|
||||
timingInfo.duration = kCMTimeInvalid;
|
||||
timingInfo.decodeTimeStamp = kCMTimeInvalid;
|
||||
timingInfo.presentationTimeStamp = pts;
|
||||
|
||||
CMSampleBufferRef sampleBuffer = NULL;
|
||||
status = CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault,
|
||||
pixelBuffer,
|
||||
videoInfo,
|
||||
&timingInfo,
|
||||
&sampleBuffer);
|
||||
CFRelease(videoInfo);
|
||||
|
||||
if (status != noErr) {
|
||||
if (sampleBuffer) {
|
||||
CFRelease(sampleBuffer);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// 标记立即显示
|
||||
CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, YES);
|
||||
if (attachments && CFArrayGetCount(attachments) > 0) {
|
||||
CFMutableDictionaryRef attachment =
|
||||
(CFMutableDictionaryRef)CFArrayGetValueAtIndex(attachments, 0);
|
||||
CFDictionarySetValue(attachment,
|
||||
kCMSampleAttachmentKey_DisplayImmediately,
|
||||
kCFBooleanTrue);
|
||||
}
|
||||
|
||||
return sampleBuffer; // 调用方 CFRelease
|
||||
}
|
||||
|
||||
#pragma mark - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController
|
||||
setPlaying:(BOOL)playing {
|
||||
NSLog(@"[PiP] setPlaying = %d", playing);
|
||||
}
|
||||
|
||||
- (CMTimeRange)pictureInPictureControllerTimeRangeForPlayback:(AVPictureInPictureController *)pictureInPictureController {
|
||||
return CMTimeRangeMake(kCMTimeZero, CMTimeMake(INT64_MAX, 1000));
|
||||
}
|
||||
|
||||
- (BOOL)pictureInPictureControllerIsPlaybackPaused:(AVPictureInPictureController *)pictureInPictureController {
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController
|
||||
didTransitionToRenderSize:(CMVideoDimensions)newRenderSize {
|
||||
NSLog(@"[PiP] render size = %d x %d", newRenderSize.width, newRenderSize.height);
|
||||
}
|
||||
|
||||
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController
|
||||
skipByInterval:(CMTime)skipInterval
|
||||
completionHandler:(void (^)(void))completionHandler {
|
||||
if (completionHandler) {
|
||||
completionHandler();
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)pictureInPictureControllerShouldProhibitBackgroundAudioPlayback:(AVPictureInPictureController *)pictureInPictureController {
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)invalidatePlaybackState {
|
||||
// 实时流一般不用
|
||||
}
|
||||
|
||||
#pragma mark - AVPictureInPictureControllerDelegate
|
||||
|
||||
- (void)pictureInPictureControllerWillStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
|
||||
NSLog(@"[PiP] will start");
|
||||
self.pipActive = YES;
|
||||
}
|
||||
|
||||
- (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
|
||||
NSLog(@"[PiP] did start");
|
||||
self.pipActive = YES;
|
||||
}
|
||||
|
||||
- (void)pictureInPictureControllerDidStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
|
||||
NSLog(@"[PiP] did stop");
|
||||
self.pipActive = NO;
|
||||
}
|
||||
|
||||
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController
|
||||
failedToStartPictureInPictureWithError:(NSError *)error {
|
||||
NSLog(@"[PiP] ❌❌❌ failed to start with error: %@", error);
|
||||
self.pipActive = NO;
|
||||
}
|
||||
|
||||
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:(void (^)(BOOL))completionHandler {
|
||||
NSLog(@"[PiP] restore UI");
|
||||
if (completionHandler) {
|
||||
completionHandler(YES);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)invalidate {
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (self.pipController.isPictureInPictureActive) {
|
||||
[self.pipController stopPictureInPicture];
|
||||
}
|
||||
self.pipController.delegate = nil;
|
||||
self.pipController = nil;
|
||||
[self.pipSampleBufferLayer flush];
|
||||
[self.pipSampleBufferLayer removeFromSuperlayer];
|
||||
self.pipSampleBufferLayer = nil;
|
||||
self.pipPossible = NO;
|
||||
self.pipActive = NO;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self invalidate];
|
||||
}
|
||||
|
||||
@end
|
||||
29
Example/SellyCloudSDK/VideoCall/SellyCallStatsView.h
Normal file
29
Example/SellyCloudSDK/VideoCall/SellyCallStatsView.h
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SellyCallStatsView.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 12/17/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SellyCallStatsView : UIView
|
||||
|
||||
// 更新统计数据
|
||||
- (void)updateBitrate:(NSString *)bitrate;
|
||||
- (void)updateVideoFps:(NSString *)fps;
|
||||
- (void)updateRtt:(NSString *)rtt;
|
||||
- (void)updateCodec:(NSString *)codec;
|
||||
- (void)updateDuration:(NSString *)duration;
|
||||
- (void)updateVideoSize:(NSString *)videoSize;
|
||||
|
||||
// 显示/隐藏
|
||||
- (void)show;
|
||||
- (void)hide;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
163
Example/SellyCloudSDK/VideoCall/SellyCallStatsView.m
Normal file
163
Example/SellyCloudSDK/VideoCall/SellyCallStatsView.m
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// SellyCallStatsView.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 12/17/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SellyCallStatsView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface SellyCallStatsView ()
|
||||
|
||||
@property (nonatomic, strong) UIView *containerView;
|
||||
@property (nonatomic, strong) UILabel *bitrateLabel;
|
||||
@property (nonatomic, strong) UILabel *videoFpsLabel;
|
||||
@property (nonatomic, strong) UILabel *rttLabel;
|
||||
@property (nonatomic, strong) UILabel *codecLabel;
|
||||
@property (nonatomic, strong) UILabel *durationLabel;
|
||||
@property (nonatomic, strong) UILabel *videoSizeLabel;
|
||||
|
||||
@property (nonatomic, strong) UIStackView *stackView;
|
||||
|
||||
@end
|
||||
|
||||
@implementation SellyCallStatsView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
// 创建半透明背景容器
|
||||
self.containerView = [[UIView alloc] init];
|
||||
self.containerView.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.6];
|
||||
self.containerView.layer.cornerRadius = 8;
|
||||
self.containerView.clipsToBounds = YES;
|
||||
[self addSubview:self.containerView];
|
||||
|
||||
// 创建垂直 StackView
|
||||
self.stackView = [[UIStackView alloc] init];
|
||||
self.stackView.axis = UILayoutConstraintAxisVertical;
|
||||
self.stackView.distribution = UIStackViewDistributionFillEqually;
|
||||
self.stackView.alignment = UIStackViewAlignmentLeading;
|
||||
self.stackView.spacing = 4;
|
||||
[self.containerView addSubview:self.stackView];
|
||||
|
||||
// 创建标签
|
||||
self.durationLabel = [self createStatLabel];
|
||||
self.bitrateLabel = [self createStatLabel];
|
||||
self.videoFpsLabel = [self createStatLabel];
|
||||
self.rttLabel = [self createStatLabel];
|
||||
self.codecLabel = [self createStatLabel];
|
||||
self.videoSizeLabel = [self createStatLabel];
|
||||
|
||||
// 添加到 StackView
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"时长:" label:self.durationLabel]];
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"码率:" label:self.bitrateLabel]];
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"帧率:" label:self.videoFpsLabel]];
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"RTT:" label:self.rttLabel]];
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"编码:" label:self.codecLabel]];
|
||||
[self.stackView addArrangedSubview:[self createRowWithTitle:@"分辨率:" label:self.videoSizeLabel]];
|
||||
|
||||
// 布局
|
||||
[self.containerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.stackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.containerView).insets(UIEdgeInsetsMake(12, 12, 12, 12));
|
||||
}];
|
||||
}
|
||||
|
||||
- (UILabel *)createStatLabel {
|
||||
UILabel *label = [[UILabel alloc] init];
|
||||
label.textColor = [UIColor whiteColor];
|
||||
label.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular];
|
||||
label.text = @"--";
|
||||
return label;
|
||||
}
|
||||
|
||||
- (UIView *)createRowWithTitle:(NSString *)title label:(UILabel *)valueLabel {
|
||||
UIView *row = [[UIView alloc] init];
|
||||
|
||||
UILabel *titleLabel = [[UILabel alloc] init];
|
||||
titleLabel.text = title;
|
||||
titleLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.7];
|
||||
titleLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightMedium];
|
||||
[row addSubview:titleLabel];
|
||||
|
||||
[row addSubview:valueLabel];
|
||||
|
||||
[titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.leading.centerY.equalTo(row);
|
||||
make.width.mas_equalTo(60);
|
||||
}];
|
||||
|
||||
[valueLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.leading.equalTo(titleLabel.mas_trailing).offset(4);
|
||||
make.centerY.equalTo(row);
|
||||
make.trailing.lessThanOrEqualTo(row).offset(-8);
|
||||
}];
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)updateBitrate:(NSString *)bitrate {
|
||||
self.bitrateLabel.text = bitrate ?: @"--";
|
||||
}
|
||||
|
||||
- (void)updateVideoFps:(NSString *)fps {
|
||||
self.videoFpsLabel.text = fps ?: @"--";
|
||||
}
|
||||
|
||||
- (void)updateRtt:(NSString *)rtt {
|
||||
self.rttLabel.text = rtt ?: @"--";
|
||||
}
|
||||
|
||||
- (void)updateCodec:(NSString *)codec {
|
||||
self.codecLabel.text = codec ?: @"--";
|
||||
}
|
||||
|
||||
- (void)updateDuration:(NSString *)duration {
|
||||
self.durationLabel.text = duration ?: @"00:00";
|
||||
}
|
||||
|
||||
- (void)updateVideoSize:(NSString *)videoSize {
|
||||
self.videoSizeLabel.text = videoSize ?: @"--";
|
||||
}
|
||||
|
||||
- (void)show {
|
||||
self.hidden = NO;
|
||||
self.alpha = 0;
|
||||
[UIView animateWithDuration:0.3 animations:^{
|
||||
self.alpha = 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)hide {
|
||||
[UIView animateWithDuration:0.3 animations:^{
|
||||
self.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
self.hidden = YES;
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// SellyVideoCallConferenceController.h
|
||||
// SellyCloudSDK_Example
|
||||
// 多人会议
|
||||
// Created by Caleb on 11/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SellyVideoCallConferenceController : UIViewController
|
||||
@property (nonatomic, strong)NSString *channelId;
|
||||
@property (nonatomic, strong)SellyRTCVideoConfiguration *videoConfig;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// SellyVideoCallConferenceController.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 11/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SellyVideoCallConferenceController.h"
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
#import "FUManager.h"
|
||||
#import "UIView+SellyCloud.h"
|
||||
#import "SLSVideoGridView.h"
|
||||
#import "TokenGenerator.h"
|
||||
#import <ReplayKit/ReplayKit.h> //屏幕采集
|
||||
#import "SellyCallControlView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface SellyVideoCallConferenceController ()<SellyRTCSessionDelegate, SellyCallControlViewDelegate>
|
||||
|
||||
// 你的 WebRTC 会话对象
|
||||
@property (nonatomic, strong) SellyRTCSession *session;
|
||||
|
||||
@property (nonatomic, assign) BOOL localVideoEnable;
|
||||
@property (nonatomic, assign) BOOL localAudioEnable;
|
||||
@property (nonatomic, assign) BOOL speakerEnabled;
|
||||
@property (nonatomic, assign) BOOL screenShareEnabled;
|
||||
|
||||
@property (weak, nonatomic) IBOutlet SLSVideoGridView *grid;
|
||||
@property (weak, nonatomic) IBOutlet UILabel *duration;
|
||||
@property (nonatomic, strong) RPSystemBroadcastPickerView *systemBroadcastPicker;
|
||||
@property (nonatomic, strong) SellyCallControlView *controlView;
|
||||
@end
|
||||
|
||||
@implementation SellyVideoCallConferenceController
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.title = @"音视频会议";
|
||||
|
||||
[UIApplication sharedApplication].idleTimerDisabled = YES;
|
||||
|
||||
self.session.delegate = self;
|
||||
if (self.videoConfig == nil) {
|
||||
self.videoConfig = SellyRTCVideoConfiguration.defaultConfig;
|
||||
}
|
||||
self.session.videoConfig = self.videoConfig;
|
||||
|
||||
NSString *token = [TokenGenerator generateRTCCallTokenWithUserId:SellyCloudManager.sharedInstance.userId callId:self.channelId];
|
||||
[self.session startWithChannelId:self.channelId token:token];
|
||||
|
||||
//开启视频
|
||||
[self onCameraClick:nil];
|
||||
|
||||
//设置扬声器播放
|
||||
{
|
||||
//使用这种方案,通话接通前无法在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];
|
||||
}
|
||||
//显示本地view
|
||||
[self attachLocalStream];
|
||||
self.localAudioEnable = true;
|
||||
|
||||
[self addBroadcastButton];
|
||||
|
||||
[self setupControlView];
|
||||
}
|
||||
|
||||
- (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.showScreenShareButton = YES;
|
||||
|
||||
[self.view addSubview:self.controlView];
|
||||
|
||||
// 使用 Masonry 添加约束
|
||||
[self.controlView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.leading.trailing.equalTo(self.view);
|
||||
make.bottom.equalTo(self.view).offset(-20);
|
||||
make.height.mas_equalTo(200);
|
||||
}];
|
||||
|
||||
// 初始化按钮状态
|
||||
self.speakerEnabled = YES;
|
||||
[self.controlView updateSpeakerEnabled:self.speakerEnabled];
|
||||
[self.controlView updateVideoEnabled:self.localVideoEnable];
|
||||
[self.controlView updateMuteEnabled:!self.localAudioEnable];
|
||||
[self.controlView updateScreenShareEnabled:self.screenShareEnabled];
|
||||
}
|
||||
|
||||
- (void)addBroadcastButton {
|
||||
if (@available(iOS 12.0, *)) {
|
||||
self.systemBroadcastPicker = [[RPSystemBroadcastPickerView alloc]
|
||||
initWithFrame:CGRectMake(UIScreen.mainScreen.bounds.size.width-80, UIScreen.mainScreen.bounds.size.height-180, 60, 60)];
|
||||
|
||||
NSString *bundleId = [NSBundle mainBundle].bundleIdentifier;
|
||||
self.systemBroadcastPicker.preferredExtension = [NSString stringWithFormat:@"%@.ScreenShareUploader",bundleId];// 你的 extension bundle id
|
||||
self.systemBroadcastPicker.showsMicrophoneButton = false;
|
||||
}
|
||||
}
|
||||
|
||||
- (IBAction)startScreenCapture:(id)sender {
|
||||
for (UIView *view in self.systemBroadcastPicker.subviews) {
|
||||
if ([view isKindOfClass:[UIButton class]]) {
|
||||
[((UIButton *)view) sendActionsForControlEvents:(UIControlEventAllEvents)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
[super viewDidDisappear:animated];
|
||||
[self.session end];
|
||||
[UIApplication sharedApplication].idleTimerDisabled = NO;
|
||||
}
|
||||
|
||||
- (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];
|
||||
}
|
||||
|
||||
#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 SellyCallControlActionScreenShare:
|
||||
[self startScreenCapture:nil];
|
||||
break;
|
||||
|
||||
case SellyCallControlActionHangup:
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- (void)attachLocalStream {
|
||||
NSString *uid = SellyCloudManager.sharedInstance.userId;
|
||||
|
||||
// 1. Grid 布局请求一个格子
|
||||
SLSVideoTileView *container = [self.grid ensureRenderContainerForUID:uid
|
||||
displayName:uid];
|
||||
|
||||
// 2. 将本地视频绑定到 container
|
||||
SellyRTCVideoCanvas *canvas = [[SellyRTCVideoCanvas alloc] init];
|
||||
// 你的接口要求 userId,这里用字符串版本地 uid
|
||||
canvas.userId = uid;
|
||||
canvas.view = container.contentView;
|
||||
[self.session setLocalCanvas:canvas];
|
||||
}
|
||||
|
||||
#pragma marks SLSVideoEngineEvents
|
||||
|
||||
#pragma mark - SellyRTCSessionDelegate 回调桥接到 SLSVideoEngineEvents
|
||||
|
||||
/// 无法恢复的错误
|
||||
- (void)rtcSession:(SellyRTCSession *)session onError:(NSError *)error {
|
||||
// 这里看你要不要额外给业务一个错误回调,可以扩展 SLSVideoEngineEvents
|
||||
NSLog(@"rtc.onerror == %@",error);
|
||||
[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 onUserJoined:(NSString *)userId {
|
||||
SLSVideoTileView *container = [self.grid ensureRenderContainerForUID:userId
|
||||
displayName:userId];
|
||||
SellyRTCVideoCanvas *canvas = [[SellyRTCVideoCanvas alloc] init];
|
||||
canvas.userId = userId;
|
||||
canvas.view = container.contentView;
|
||||
[self.session setRemoteCanvas:canvas];
|
||||
}
|
||||
|
||||
/// 远端用户离开
|
||||
- (void)rtcSession:(SellyRTCSession *)session onUserLeave:(NSString *)userId {
|
||||
[self.grid detachUID:userId];
|
||||
}
|
||||
|
||||
- (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 didReceiveMessage:(NSString *)message userId:(NSString *)userId {
|
||||
NSLog(@"recv message from %@: %@", userId, message);
|
||||
}
|
||||
|
||||
/// 连接状态变化
|
||||
- (void)rtcSession:(SellyRTCSession *)session connectionStateChanged:(SellyRTCConnectState)state userId:(nullable NSString *)userId {
|
||||
NSLog(@"ice.connectionStateChanged == %ld",state);
|
||||
}
|
||||
|
||||
- (void)rtcSession:(SellyRTCSession *)session onRoomConnectionStateChanged:(SellyRoomConnectionState)state {
|
||||
NSLog(@"####onSocketStateChanged == %ld",(long)state);
|
||||
}
|
||||
|
||||
/// 视频前处理(如果你要做美颜,可以在这里转发给业务;目前和 UI 层无关)
|
||||
- (CVPixelBufferRef)rtcSession:(SellyRTCSession *)session onCaptureVideoFrame:(CVPixelBufferRef)pixelBuffer {
|
||||
// 默认不处理,直接返回原始帧
|
||||
return pixelBuffer;
|
||||
}
|
||||
|
||||
/// 统计信息(如果你的 SellyRTCP2pStats 里有音量相关信息,可以在这里转成 videoEngineVolumeIndication)
|
||||
- (void)rtcSession:(SellyRTCSession *)session onStats:(SellyRTCStats *)stats userId:(nullable NSString *)userId {
|
||||
// TODO: 如果 stats 里有本地 / 远端音量,可在这里构造一个 dict 调用:
|
||||
// if ([self.eventsDelegate respondsToSelector:@selector(videoEngineVolumeIndication:)]) { ... }
|
||||
SLSVideoTileView *view = [self.grid ensureRenderContainerForUID:userId displayName:nil];
|
||||
view.stats = stats;
|
||||
}
|
||||
|
||||
- (void)rtcSession:(SellyRTCSession *)session onDuration:(NSInteger)duration {
|
||||
self.duration.text = [NSString stringWithFormat:@"%02ld:%02ld",duration/60,duration%60];
|
||||
}
|
||||
|
||||
- (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];
|
||||
}
|
||||
|
||||
- (void)rtcSession:(SellyRTCSession *)session onScreenShareStatusChanged:(SellyScreenShareState)state {
|
||||
if (state == SellyScreenShareStateStarted) {
|
||||
self.screenShareEnabled = YES;
|
||||
self.localVideoEnable = false;
|
||||
[self.session startScreenCapture];
|
||||
[self.controlView updateScreenShareEnabled:self.screenShareEnabled];
|
||||
[self.controlView updateVideoEnabled:self.localVideoEnable];
|
||||
}
|
||||
else if (state == SellyScreenShareStateStopped) {
|
||||
self.screenShareEnabled = NO;
|
||||
self.localVideoEnable = true;
|
||||
[self.session enableLocalVideo:true];
|
||||
[self.controlView updateScreenShareEnabled:self.screenShareEnabled];
|
||||
[self.controlView updateVideoEnabled:self.localVideoEnable];
|
||||
}
|
||||
}
|
||||
|
||||
- (SellyRTCSession *)session {
|
||||
if (!_session) {
|
||||
_session = [[SellyRTCSession alloc] initWithType:false];
|
||||
}
|
||||
return _session;
|
||||
}
|
||||
|
||||
/*
|
||||
#pragma mark - Navigation
|
||||
|
||||
// In a storyboard-based application, you will often want to do a little preparation before navigation
|
||||
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
|
||||
// Get the new view controller using [segue destinationViewController].
|
||||
// Pass the selected object to the new view controller.
|
||||
}
|
||||
*/
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SellyVideoCallConferenceController">
|
||||
<connections>
|
||||
<outlet property="duration" destination="RWd-4c-qBr" id="7Fz-B2-8rP"/>
|
||||
<outlet property="grid" destination="x73-lB-7SC" id="vbG-wv-e2W"/>
|
||||
<outlet property="view" destination="iN0-l3-epB" id="bQ9-58-b7b"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="iN0-l3-epB">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="x73-lB-7SC" customClass="SLSVideoGridView">
|
||||
<rect key="frame" x="0.0" y="118" width="393" height="734"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RWd-4c-qBr">
|
||||
<rect key="frame" x="196.66666666666666" y="812" width="0.0" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="gL5-ag-O4i"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="x73-lB-7SC" secondAttribute="trailing" id="M9E-eI-AK2"/>
|
||||
<constraint firstItem="x73-lB-7SC" firstAttribute="top" secondItem="gL5-ag-O4i" secondAttribute="top" id="N63-KO-F8q"/>
|
||||
<constraint firstItem="x73-lB-7SC" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="emy-ft-ubu"/>
|
||||
<constraint firstAttribute="bottom" secondItem="RWd-4c-qBr" secondAttribute="bottom" constant="40" id="jMP-ES-URU"/>
|
||||
<constraint firstItem="RWd-4c-qBr" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="mQ5-Gb-AKH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="x73-lB-7SC" secondAttribute="bottom" id="vYx-gD-7SC"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="-251.14503816793894" y="123.94366197183099"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// SellyVideoCallViewController.h
|
||||
// SellyCloudSDK_Example
|
||||
// 单聊
|
||||
// Created by Caleb on 3/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <SellyCloudSDK/SellyCloudManager.h>
|
||||
//#import "SellyRTCVideoConfiguration.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SellyVideoCallViewController : UIViewController
|
||||
@property (nonatomic, strong)NSString *channelId;
|
||||
@property (nonatomic, strong)SellyRTCVideoConfiguration *videoConfig;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
383
Example/SellyCloudSDK/VideoCall/SellyVideoCallViewController.m
Normal file
383
Example/SellyCloudSDK/VideoCall/SellyVideoCallViewController.m
Normal file
@@ -0,0 +1,383 @@
|
||||
//
|
||||
// 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 == %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
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24412"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SellyVideoCallViewController">
|
||||
<connections>
|
||||
<outlet property="localView" destination="6ee-YL-4qL" id="5uS-Nk-bEw"/>
|
||||
<outlet property="remoteView" destination="0Nt-EE-wL9" id="NAM-lY-79G"/>
|
||||
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6ee-YL-4qL">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<color key="backgroundColor" systemColor="systemGray6Color"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="0Nt-EE-wL9">
|
||||
<rect key="frame" x="263" y="80" width="120" height="213"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="213" id="QBt-ql-Qt9"/>
|
||||
<constraint firstAttribute="width" constant="120" id="ung-jS-8fk"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="6ee-YL-4qL" firstAttribute="bottom" secondItem="i5M-Pr-FkT" secondAttribute="bottom" id="GIZ-eW-Ecg"/>
|
||||
<constraint firstItem="0Nt-EE-wL9" firstAttribute="trailing" secondItem="6ee-YL-4qL" secondAttribute="trailing" constant="-10" id="McM-1X-FjP"/>
|
||||
<constraint firstItem="6ee-YL-4qL" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="Vfb-hu-Vjl"/>
|
||||
<constraint firstItem="0Nt-EE-wL9" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" constant="80" id="cbL-Za-bvZ"/>
|
||||
<constraint firstItem="6ee-YL-4qL" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="nPG-bF-rIX"/>
|
||||
<constraint firstAttribute="trailing" secondItem="6ee-YL-4qL" secondAttribute="trailing" id="yuA-Bt-5p9"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="-2422.1374045801526" y="-228.16901408450704"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<systemColor name="systemGray6Color">
|
||||
<color red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
36
Example/SellyCloudSDK/VideoCall/TokenGenerator.h
Normal file
36
Example/SellyCloudSDK/VideoCall/TokenGenerator.h
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// TokenGenerator.h
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 20/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CommonCrypto/CommonCrypto.h>
|
||||
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface TokenGenerator : NSObject
|
||||
+ (NSString *)generateRTCCallTokenWithUserId:(NSString *)userId
|
||||
callId:(NSString *)callId;
|
||||
|
||||
/**
|
||||
生成流媒体签名
|
||||
|
||||
@param vhost 虚拟主机
|
||||
@param appId 应用ID
|
||||
@param channelId 频道ID
|
||||
@param type 类型(push 或 pull)
|
||||
@param key 对应的 push_key 或 pull_key
|
||||
@return 签名字符串
|
||||
*/
|
||||
+ (NSString *)generateStreamSignatureWithVhost:(NSString *)vhost
|
||||
appId:(NSString *)appId
|
||||
channelId:(NSString *)channelId
|
||||
type:(NSString *)type
|
||||
key:(NSString *)key;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
93
Example/SellyCloudSDK/VideoCall/TokenGenerator.m
Normal file
93
Example/SellyCloudSDK/VideoCall/TokenGenerator.m
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// TokenGenerator.m
|
||||
// SellyCloudSDK_Example
|
||||
//
|
||||
// Created by Caleb on 20/11/25.
|
||||
// Copyright © 2025 Caleb. All rights reserved.
|
||||
//
|
||||
|
||||
#import "TokenGenerator.h"
|
||||
|
||||
#define KSecret @"CHANGE_ME"
|
||||
#define CALL_APP_ID @"demo-app"
|
||||
|
||||
@implementation TokenGenerator
|
||||
|
||||
+ (NSString *)generateRTCCallTokenWithUserId:(NSString *)userId
|
||||
callId:(NSString *)callId
|
||||
{
|
||||
// 当前时间戳(秒)
|
||||
long signTime = (long)[[NSDate date] timeIntervalSince1970];
|
||||
long exprTime = signTime + 60; // 10 分钟后过期
|
||||
|
||||
// payload 拼接
|
||||
NSString *payload = [NSString stringWithFormat:@"%@%@%@%ld%ld",
|
||||
CALL_APP_ID, userId, callId, signTime, exprTime];
|
||||
|
||||
// HMAC-SHA256 计算
|
||||
const char *cKey = [KSecret cStringUsingEncoding:NSUTF8StringEncoding];
|
||||
const char *cData = [payload cStringUsingEncoding:NSUTF8StringEncoding];
|
||||
|
||||
unsigned char cHMAC[CC_SHA256_DIGEST_LENGTH];
|
||||
CCHmac(kCCHmacAlgSHA256, cKey, strlen(cKey), cData, strlen(cData), cHMAC);
|
||||
|
||||
// 转 hex 字符串
|
||||
NSMutableString *sign = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
|
||||
[sign appendFormat:@"%02x", cHMAC[i]];
|
||||
}
|
||||
|
||||
// 拼接最终 Token
|
||||
NSString *token = [NSString stringWithFormat:
|
||||
@"appid=%@&userid=%@&callid=%@&signtime=%ld&exprtime=%ld&sign=%@",
|
||||
CALL_APP_ID, userId, callId, signTime, exprTime, sign];
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
+ (NSString *)generateStreamSignatureWithVhost:(NSString *)vhost
|
||||
appId:(NSString *)appId
|
||||
channelId:(NSString *)channelId
|
||||
type:(NSString *)type
|
||||
key:(NSString *)key
|
||||
{
|
||||
// 当前时间戳(秒)
|
||||
long signTm = (long)[[NSDate date] timeIntervalSince1970];
|
||||
long expireTm = signTm + 600; // 10 分钟后过期
|
||||
|
||||
// 拼接签名字符串: vhost|app_id|channel_id|sign_tm|expire_tm|type
|
||||
NSString *payload = [NSString stringWithFormat:@"%@|%@|%@|%ld|%ld|%@",
|
||||
vhost, appId, channelId, signTm, expireTm, type];
|
||||
|
||||
// 使用 key 生成签名
|
||||
NSString *sign = [self generateSign:payload withKey:key];
|
||||
|
||||
// 拼接完整 payload: vhost|app_id|channel_id|sign_tm|expire_tm|type|sign
|
||||
NSString *fullPayload = [NSString stringWithFormat:@"%@|%@", payload, sign];
|
||||
NSLog(@"###fullPayload == %@",fullPayload);
|
||||
|
||||
// Base64 编码
|
||||
NSData *data = [fullPayload dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSString *base64String = [data base64EncodedStringWithOptions:0];
|
||||
|
||||
return base64String;
|
||||
}
|
||||
|
||||
+ (NSString *)generateSign:(NSString *)payload withKey:(NSString *)key {
|
||||
// HMAC-SHA256 计算
|
||||
const char *cKey = [key cStringUsingEncoding:NSUTF8StringEncoding];
|
||||
const char *cData = [payload cStringUsingEncoding:NSUTF8StringEncoding];
|
||||
|
||||
unsigned char cHMAC[CC_SHA256_DIGEST_LENGTH];
|
||||
CCHmac(kCCHmacAlgSHA256, cKey, strlen(cKey), cData, strlen(cData), cHMAC);
|
||||
|
||||
// 转 hex 字符串
|
||||
NSMutableString *sign = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
|
||||
[sign appendFormat:@"%02x", cHMAC[i]];
|
||||
}
|
||||
|
||||
return sign;
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user