initial commit

This commit is contained in:
Caleb
2026-03-01 15:59:27 +08:00
commit a9e97d56cb
1426 changed files with 172367 additions and 0 deletions

View 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

View 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

View 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

View 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/21/31/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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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];
//
{
//使receiverspeaker
// [self.session setAudioOutput:AVAudioSessionPortOverrideSpeaker];
}
{
//使receiverspeaker
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

View File

@@ -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>

View File

@@ -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

View 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];
//
{
//使receiverspeaker
// [self.session setAudioOutput:AVAudioSessionPortOverrideSpeaker];
}
{
//使receiverspeaker
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 canvaslocalView/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

View File

@@ -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>

View 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

View 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