rtmp、rtc推拉流支持加密

This commit is contained in:
caleb
2026-04-07 16:35:04 +08:00
parent bc56b7851d
commit 88800334ec
19 changed files with 370 additions and 147 deletions

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
0897849CBA2960C1F1BE2DC4 /* Pods_SellyCloudSDK_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3A5DE9B7559BAE46EA68112 /* Pods_SellyCloudSDK_Tests.framework */; };
0D0ECA322F83CFDC00917568 /* AVApiService.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D0ECA302F83CFDC00917568 /* AVApiService.m */; };
236246B1275D4CD3B91DACE1 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 353107A80A9449DFB01DACE1 /* README.md */; };
3C075C2B2E3873A800591B2D /* test1.png in Resources */ = {isa = PBXBuildFile; fileRef = 3C075C2A2E3873A800591B2D /* test1.png */; };
3C0F91622EF39F0000680CB7 /* SCNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C0F91612EF39F0000680CB7 /* SCNavigationController.m */; };
@@ -122,6 +123,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0D0ECA2F2F83CFDC00917568 /* AVApiService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AVApiService.h; sourceTree = "<group>"; };
0D0ECA302F83CFDC00917568 /* AVApiService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AVApiService.m; sourceTree = "<group>"; };
11F5CE3EA9A94E55D1A35A8F /* SellyCloudSDK.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = SellyCloudSDK.podspec; path = ../SellyCloudSDK.podspec; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
18585A8A04A38911555AC335 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
28EA6D0E21FA7AAFE4E2C53C /* Pods-SellyCloudSDK_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SellyCloudSDK_Example.release.xcconfig"; path = "Target Support Files/Pods-SellyCloudSDK_Example/Pods-SellyCloudSDK_Example.release.xcconfig"; sourceTree = "<group>"; };
@@ -314,6 +317,15 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0D0ECA312F83CFDC00917568 /* Network */ = {
isa = PBXGroup;
children = (
0D0ECA2F2F83CFDC00917568 /* AVApiService.h */,
0D0ECA302F83CFDC00917568 /* AVApiService.m */,
);
path = Network;
sourceTree = "<group>";
};
3C075D132E3B474A00591B2D /* Play */ = {
isa = PBXGroup;
children = (
@@ -519,6 +531,7 @@
6003F593195388D20070C39A /* Example for SellyCloudSDK */ = {
isa = PBXGroup;
children = (
0D0ECA312F83CFDC00917568 /* Network */,
3CC732F92EF0EEF4000027B2 /* Controllers */,
3CC732FE2EF0EEF4000027B2 /* Managers */,
3CC733052EF0EEF4000027B2 /* Utils */,
@@ -984,6 +997,7 @@
3C1851E02ECDE7690022F536 /* SellyCallPiPManager.m in Sources */,
3CC7335F2EF24A4C000027B2 /* SellyCallStatsView.m in Sources */,
3C4BF3132EC347510095F93A /* SellyVideoCallConferenceController.m in Sources */,
0D0ECA322F83CFDC00917568 /* AVApiService.m in Sources */,
3CDB90672EFF908100FBC4E6 /* AVLiveStreamModel.m in Sources */,
3C8AC1F12EB85E4E000A58F1 /* SellyVideoCallViewController.m in Sources */,
3C312FFC2F021216006C90A4 /* AVLoginViewController.m in Sources */,

View File

@@ -4,15 +4,11 @@
//
#import "AVUserManager.h"
#import <AFNetworking/AFNetworking.h>
#import "AVApiService.h"
static NSString * const kUserLoggedInKey = @"AVUserLoggedIn";
static NSString * const kUserUsernameKey = @"AVUserUsername";
@interface AVUserManager ()
@property (nonatomic, strong) AFHTTPSessionManager *sessionManager;
@end
@implementation AVUserManager
+ (instancetype)sharedManager {
@@ -27,15 +23,8 @@ static NSString * const kUserUsernameKey = @"AVUserUsername";
- (instancetype)init {
self = [super init];
if (self) {
// UserDefaults
_isLoggedIn = [[NSUserDefaults standardUserDefaults] boolForKey:kUserLoggedInKey];
_currentUsername = [[NSUserDefaults standardUserDefaults] stringForKey:kUserUsernameKey];
//
self.sessionManager = [AFHTTPSessionManager manager];
self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer];
self.sessionManager.requestSerializer = [AFJSONRequestSerializer serializer];
self.sessionManager.requestSerializer.timeoutInterval = 10;
}
return self;
}
@@ -43,88 +32,23 @@ static NSString * const kUserUsernameKey = @"AVUserUsername";
- (void)loginWithUsername:(NSString *)username
password:(NSString *)password
completion:(void (^)(BOOL success, NSString * _Nullable errorMessage))completion {
//
if (username.length == 0) {
if (completion) {
completion(NO, @"用户名不能为空");
}
if (completion) completion(NO, @"用户名不能为空");
return;
}
if (password.length == 0) {
if (completion) {
completion(NO, @"密码不能为空");
}
if (completion) completion(NO, @"密码不能为空");
return;
}
// API
NSString *loginURL = @"http://rtmp.sellycloud.io:8089/live/sdk/demo/login";
//
NSDictionary *parameters = @{
@"username": username,
@"password": password
};
NSLog(@"🚀 开始登录请求: %@", username);
// POST
[self.sessionManager POST:loginURL parameters:parameters headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
//
NSLog(@"✅ 登录成功,服务器响应: %@", responseObject);
//
[[AVApiService shared] loginWithUsername:username password:password
success:^(id responseObject) {
[self saveLoginStateWithUsername:username];
// tokenuserId
//
// if (responseObject[@"token"]) {
// [[NSUserDefaults standardUserDefaults] setObject:responseObject[@"token"] forKey:@"AVUserToken"];
// }
//
if (completion) {
completion(YES, nil);
}
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
//
NSLog(@"❌ 登录失败: %@", error.localizedDescription);
NSString *errorMessage = @"登录失败,请检查用户名和密码";
// HTTP
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
if (httpResponse) {
NSLog(@"❌ HTTP 状态码: %ld", (long)httpResponse.statusCode);
}
//
if (error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey]) {
NSData *errorData = error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey];
NSDictionary *errorDict = [NSJSONSerialization JSONObjectWithData:errorData options:0 error:nil];
NSLog(@"❌ 服务器错误响应: %@", errorDict);
//
if (errorDict[@"message"]) {
errorMessage = errorDict[@"message"];
} else if (errorDict[@"error"]) {
errorMessage = errorDict[@"error"];
} else if (errorDict[@"msg"]) {
errorMessage = errorDict[@"msg"];
}
} else {
//
errorMessage = [NSString stringWithFormat:@"网络请求失败: %@", error.localizedDescription];
}
//
if (completion) {
completion(NO, errorMessage);
if (completion) completion(YES, nil);
}
failure:^(NSError *error, NSString *serverMessage) {
NSString *msg = serverMessage ?: [NSString stringWithFormat:@"网络请求失败: %@", error.localizedDescription];
if (completion) completion(NO, msg);
}];
}

View File

@@ -13,9 +13,7 @@
#import "AVLiveStreamModel.h"
#import "AVLiveStreamCell.h"
#import "SCPlayerConfigView.h"
#import <AFNetworking/AFNetworking.h>
#import <AFNetworking/UIImageView+AFNetworking.h>
#import <YYModel/YYModel.h>
#import "AVApiService.h"
@interface AVHomeViewController () <UICollectionViewDelegate, UICollectionViewDataSource>
@property (nonatomic, strong) UIView *headerView;
@@ -23,13 +21,11 @@
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) NSArray<AVLiveStreamModel *> *liveStreams;
@property (nonatomic, strong) UIRefreshControl *refreshControl;
@property (nonatomic, strong) AFHTTPSessionManager *sessionManager;
@end
@implementation AVHomeViewController
static NSString * const kLiveStreamCellIdentifier = @"LiveStreamCell";
static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/sdk/alive-list";
- (void)viewDidLoad {
[super viewDidLoad];
@@ -53,11 +49,6 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/
//
self.liveStreams = [NSMutableArray array];
// AFNetworking
self.sessionManager = [AFHTTPSessionManager manager];
self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer];
self.sessionManager.requestSerializer.timeoutInterval = 10;
[self setupHeaderButtons];
[self setupCollectionView];
@@ -191,30 +182,20 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/
- (void)fetchLiveStreams {
__weak typeof(self) weakSelf = self;
[self.sessionManager GET:kLiveListAPIURL parameters:nil headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, NSDictionary *responseObject) {
[[AVApiService shared] fetchLiveStreams:^(NSArray<AVLiveStreamModel *> *streams) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
NSLog(@"%@",responseObject);
[strongSelf.refreshControl endRefreshing];
//
strongSelf.liveStreams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]];
NSLog(@"✅ 成功获取 %ld 个直播流", (long)strongSelf.liveStreams.count);
// UI
strongSelf.liveStreams = streams;
NSLog(@"✅ 成功获取 %ld 个直播流", (long)streams.count);
[strongSelf.collectionView reloadData];
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
} failure:^(NSError *error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf.refreshControl endRefreshing];
NSLog(@"❌ 网络请求失败: %@", error.localizedDescription);
//
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"加载失败"
message:[NSString stringWithFormat:@"无法获取直播列表: %@", error.localizedDescription]
message:error.localizedDescription
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
[strongSelf presentViewController:alert animated:YES completion:nil];
@@ -349,6 +330,7 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/
liveStream.preview_image = @"";
liveStream.duration = 0;
liveStream.startTime = [[NSDate date] timeIntervalSince1970];
liveStream.xorKey = config.xorKey;
// playProtocol
switch (config.protocol) {

View File

@@ -17,6 +17,7 @@
@property (nonatomic, strong) UILabel *nameLabel;
@property (nonatomic, strong) UILabel *liveLabel;
@property (nonatomic, strong) UILabel *protocolLabel;
@property (nonatomic, strong) UIImageView *encryptIcon;
@end
@@ -141,6 +142,19 @@
make.centerY.equalTo(_liveLabel);
make.height.equalTo(@16);
make.width.greaterThanOrEqualTo(@40);
}];
//
_encryptIcon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"lock.shield.fill"]];
_encryptIcon.tintColor = [UIColor systemOrangeColor];
_encryptIcon.contentMode = UIViewContentModeScaleAspectFit;
_encryptIcon.hidden = YES;
[infoContainerView addSubview:_encryptIcon];
[_encryptIcon mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(_protocolLabel.mas_right).offset(6);
make.centerY.equalTo(_liveLabel);
make.width.height.equalTo(@20);
make.right.lessThanOrEqualTo(infoContainerView);
}];
}
@@ -155,6 +169,9 @@
//
_protocolLabel.text = model.play_protocol.uppercaseString;
//
_encryptIcon.hidden = (model.xorKey.length == 0);
// 使 SDWebImage
if (model.preview_image.length > 0) {
NSURL *imageURL = [NSURL URLWithString:model.preview_image];
@@ -177,6 +194,7 @@
_nameLabel.text = @"";
_durationLabel.text = @"";
_protocolLabel.text = @"";
_encryptIcon.hidden = YES;
}
@end

View File

@@ -27,6 +27,9 @@ NS_ASSUME_NONNULL_BEGIN
/// 视频编码器H.264/H.265
@property (nonatomic, assign) AVVideoCodec codec;
/// XOR 加密密钥(十六进制字符串,如 @"AABBCCDD"),为空则不加密
@property (nonatomic, copy, nullable) NSString *xorKey;
// ============ 工厂方法 ============
/// 创建默认配置

View File

@@ -18,8 +18,7 @@
#import "TokenGenerator.h"
#import "AVLiveStreamModel.h"
#import "AVConstants.h"
#import <AFNetworking/AFNetworking.h>
#import <YYModel/YYModel.h>
#import "AVApiService.h"
@interface SCLivePusherViewController ()<SellyLivePusherDelegate, SellyLivePlayerDelegate>
@property (nonatomic, strong)UIView *liveView;
@@ -196,6 +195,16 @@
[AVConfigManager.sharedManager saveConfig];
if (!self.isLiveStarted) {
//
NSString *xorKeyError = [self validateXorKey:self.videoConfig.xorKey];
if (xorKeyError) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"密钥格式错误"
message:xorKeyError
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
return;
}
//
[self startLive];
self.isLiveStarted = YES;
@@ -449,6 +458,7 @@
NSString *token = [TokenGenerator generateStreamSignatureWithVhost:V_HOST appId:APP_ID channelId:self.videoConfig.streamId type:@"push" key:APP_SECRET];
self.livePusher.token = token;
self.livePusher.xorKey = self.videoConfig.xorKey;
NSError *error;
if (self.videoConfig.streamId) {
@@ -460,6 +470,31 @@
if (error) {
NSLog(@"###startLive failed. error == %@",error.localizedDescription);
}
// XOR key
[self reportXorKey];
}
/// xorKey nil
- (NSString *)validateXorKey:(NSString *)xorKey {
if (xorKey.length == 0) return nil;
if (xorKey.length % 2 != 0) {
return @"加密密钥必须为偶数长度";
}
NSCharacterSet *hexChars = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"];
if ([xorKey rangeOfCharacterFromSet:hexChars.invertedSet].location != NSNotFound) {
return @"加密密钥只能包含十六进制字符 (0-9, a-f, A-F)";
}
return nil;
}
- (void)reportXorKey {
[[AVApiService shared] reportXorKeyWithVhost:V_HOST
app:APP_ID
stream:self.videoConfig.streamId
xorKey:self.videoConfig.xorKey
success:nil
failure:nil];
}
- (void)setupItemContainer {
@@ -724,47 +759,27 @@
[self presentViewController:loadingAlert animated:YES completion:nil];
//
AFHTTPSessionManager *sessionManager = [AFHTTPSessionManager manager];
sessionManager.responseSerializer = [AFJSONResponseSerializer serializer];
sessionManager.requestSerializer.timeoutInterval = 10;
__weak typeof(self) weakSelf = self;
NSString *apiURL = @"http://rtmp.sellycloud.io:8089/live/sdk/alive-list";
[sessionManager GET:apiURL parameters:nil headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, NSDictionary *responseObject) {
[[AVApiService shared] fetchLiveStreams:^(NSArray<AVLiveStreamModel *> *streams) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
//
[loadingAlert dismissViewControllerAnimated:YES completion:^{
//
NSArray<AVLiveStreamModel *> *liveStreams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]];
NSLog(@"✅ 获取 %ld 个直播流", (long)liveStreams.count);
if (liveStreams.count == 0) {
//
if (streams.count == 0) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示"
message:@"暂无正在直播的用户"
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
[strongSelf presentViewController:alert animated:YES completion:nil];
} else {
//
[strongSelf showStreamListWithStreams:liveStreams];
[strongSelf showStreamListWithStreams:streams];
}
}];
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
} failure:^(NSError *error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
//
[loadingAlert dismissViewControllerAnimated:YES completion:^{
NSLog(@"❌ 获取直播列表失败: %@", error.localizedDescription);
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"加载失败"
message:[NSString stringWithFormat:@"无法获取直播列表: %@", error.localizedDescription]
message:error.localizedDescription
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
[strongSelf presentViewController:alert animated:YES completion:nil];

View File

@@ -0,0 +1,39 @@
//
// AVApiService.h
// SellyCloudSDK_Example
//
#import <Foundation/Foundation.h>
@class AVLiveStreamModel;
NS_ASSUME_NONNULL_BEGIN
typedef void(^AVApiSuccess)(id _Nullable responseObject);
typedef void(^AVApiFailure)(NSError *error);
@interface AVApiService : NSObject
+ (instancetype)shared;
/// GET /live/sdk/alive-list
- (void)fetchLiveStreams:(void(^)(NSArray<AVLiveStreamModel *> *streams))success
failure:(nullable AVApiFailure)failure;
/// POST /live/sdk/demo/stream-xor
- (void)reportXorKeyWithVhost:(NSString *)vhost
app:(NSString *)app
stream:(NSString *)stream
xorKey:(nullable NSString *)xorKey
success:(nullable AVApiSuccess)success
failure:(nullable AVApiFailure)failure;
/// POST /live/sdk/demo/login
- (void)loginWithUsername:(NSString *)username
password:(NSString *)password
success:(void(^)(id responseObject))success
failure:(void(^)(NSError *error, NSString * _Nullable serverMessage))failure;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,101 @@
//
// AVApiService.m
// SellyCloudSDK_Example
//
#import "AVApiService.h"
#import "AVLiveStreamModel.h"
#import <AFNetworking/AFNetworking.h>
#import <YYModel/YYModel.h>
static NSString * const kBaseURL = @"http://rtmp.sellycloud.io:8089";
@interface AVApiService ()
@property (nonatomic, strong) AFHTTPSessionManager *manager;
@end
@implementation AVApiService
+ (instancetype)shared {
static AVApiService *instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[AVApiService alloc] init];
});
return instance;
}
- (instancetype)init {
self = [super init];
if (self) {
_manager = [AFHTTPSessionManager manager];
_manager.requestSerializer = [AFJSONRequestSerializer serializer];
_manager.responseSerializer = [AFJSONResponseSerializer serializer];
_manager.requestSerializer.timeoutInterval = 10;
}
return self;
}
- (void)fetchLiveStreams:(void(^)(NSArray<AVLiveStreamModel *> *streams))success
failure:(nullable AVApiFailure)failure {
NSString *url = [NSString stringWithFormat:@"%@/live/sdk/alive-list", kBaseURL];
[self.manager GET:url parameters:nil headers:nil progress:nil
success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"%@",responseObject);
NSArray *streams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]];
if (success) success(streams ?: @[]);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
NSLog(@"[AVApiService] fetchLiveStreams failed: %@", error.localizedDescription);
if (failure) failure(error);
}];
}
- (void)reportXorKeyWithVhost:(NSString *)vhost
app:(NSString *)app
stream:(NSString *)stream
xorKey:(nullable NSString *)xorKey
success:(nullable AVApiSuccess)success
failure:(nullable AVApiFailure)failure {
NSDictionary *params = @{
@"vhost": vhost ?: @"",
@"app": app ?: @"",
@"stream": stream ?: @"",
@"xor_key": xorKey ?: @""
};
NSString *url = [NSString stringWithFormat:@"%@/live/sdk/demo/stream-xor", kBaseURL];
[self.manager POST:url parameters:params headers:nil progress:nil
success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"[AVApiService] reportXorKey success: %@", responseObject);
if (success) success(responseObject);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
NSLog(@"[AVApiService] reportXorKey failed: %@", error.localizedDescription);
if (failure) failure(error);
}];
}
- (void)loginWithUsername:(NSString *)username
password:(NSString *)password
success:(void(^)(id responseObject))success
failure:(void(^)(NSError *error, NSString * _Nullable serverMessage))failure {
NSDictionary *params = @{
@"username": username ?: @"",
@"password": password ?: @""
};
NSString *url = [NSString stringWithFormat:@"%@/live/sdk/demo/login", kBaseURL];
[self.manager POST:url parameters:params headers:nil progress:nil
success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"[AVApiService] login success");
if (success) success(responseObject);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
NSLog(@"[AVApiService] login failed: %@", error.localizedDescription);
NSString *serverMessage = nil;
NSData *errorData = error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey];
if (errorData) {
NSDictionary *errorDict = [NSJSONSerialization JSONObjectWithData:errorData options:0 error:nil];
serverMessage = errorDict[@"message"] ?: errorDict[@"error"] ?: errorDict[@"msg"];
}
if (failure) failure(error, serverMessage);
}];
}
@end

View File

@@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) NSTimeInterval duration;
@property (nonatomic, copy) NSString *preview_image; // 预览图 URL
@property (nonatomic, copy) NSString *play_protocol; // 播放协议rtc, rtmp, flv, hls 等
@property (nonatomic, copy, nullable) NSString *xorKey; // XOR 解密密钥 (十六进制字符串)
- (NSString *)displayName;
- (NSString *)durationString;

View File

@@ -7,6 +7,12 @@
@implementation AVLiveStreamModel
+ (NSDictionary *)modelCustomPropertyMapper {
return @{
@"xorKey" : @"xor_key"
};
}
- (NSString *)displayName {
return self.stream.length > 0 ? self.stream : @"未知流";
}

View File

@@ -387,6 +387,7 @@
NSString *token = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:config.streamId type:@"pull" key:APP_SECRET];
SellyLiveVideoPlayer *player = [[SellyLiveVideoPlayer alloc] init];
player.token = token;
player.xorKey = self.streamModel.xorKey ?: config.xorKey;
player.delegate = self;
player.scaleMode = SellyPlayerScalingModeAspectFit;
[player setRenderView:self.playerContainerView];
@@ -405,6 +406,7 @@
NSString *pkToken = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:self.pkConfig.streamId type:@"pull" key:APP_SECRET];
SellyLiveVideoPlayer *pkPlayer = [[SellyLiveVideoPlayer alloc] init];
pkPlayer.token = pkToken;
pkPlayer.xorKey = self.streamModel.xorKey ?: self.currentConfig.xorKey;
pkPlayer.delegate = self;
pkPlayer.scaleMode = SellyPlayerScalingModeAspectFit;
[pkPlayer setRenderView:self.pkPlayerContainerView];

View File

@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface SCPlayerConfig : NSObject
@property (nonatomic, assign) SellyLiveMode protocol;
@property (nonatomic, strong) NSString *streamId;
@property (nonatomic, copy, nullable) NSString *xorKey;
/// 保存配置到 UserDefaults
- (void)saveToUserDefaults;

View File

@@ -12,6 +12,7 @@
// key
static NSString * const kSCPlayerConfigProtocol = @"SCPlayerConfigProtocol";
static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
static NSString * const kSCPlayerConfigXorKey = @"SCPlayerConfigXorKey";
@implementation SCPlayerConfig
@@ -21,6 +22,11 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
if (self.streamId) {
[defaults setObject:self.streamId forKey:kSCPlayerConfigStreamId];
}
if (self.xorKey.length > 0) {
[defaults setObject:self.xorKey forKey:kSCPlayerConfigXorKey];
} else {
[defaults removeObjectForKey:kSCPlayerConfigXorKey];
}
[defaults synchronize];
}
@@ -40,6 +46,9 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
NSString *streamId = [defaults objectForKey:kSCPlayerConfigStreamId];
config.streamId = streamId ? streamId : @"test";
// xorKey
config.xorKey = [defaults objectForKey:kSCPlayerConfigXorKey];
return config;
}
@@ -50,6 +59,7 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
@property (nonatomic, strong) UIVisualEffectView *backgroundView;
@property (nonatomic, strong) UISegmentedControl *protocolSegment;
@property (nonatomic, strong) UITextField *streamIdField;
@property (nonatomic, strong) UITextField *xorKeyField;
@property (nonatomic, strong) UIButton *playButton;
@property (nonatomic, copy) void(^callback)(SCPlayerConfig *config);
@property (nonatomic, strong) MASConstraint *backgroundViewCenterYConstraint; //
@@ -172,6 +182,35 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
make.height.offset(44);
}];
//
UILabel *xorKeyLabel = [[UILabel alloc] init];
xorKeyLabel.text = @"加密密钥 (Hex),留空不解密";
xorKeyLabel.font = [UIFont systemFontOfSize:14];
xorKeyLabel.numberOfLines = 0;
[_contentView addSubview:xorKeyLabel];
[xorKeyLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(_streamIdField.mas_bottom).offset(24);
make.left.right.equalTo(_contentView);
make.height.offset(20);
}];
//
_xorKeyField = [[UITextField alloc] init];
_xorKeyField.placeholder = @"如 AABBCCDD";
_xorKeyField.borderStyle = UITextBorderStyleRoundedRect;
_xorKeyField.font = [UIFont systemFontOfSize:15];
_xorKeyField.clearButtonMode = UITextFieldViewModeWhileEditing;
_xorKeyField.returnKeyType = UIReturnKeyDone;
_xorKeyField.delegate = self;
_xorKeyField.autocapitalizationType = UITextAutocapitalizationTypeNone;
_xorKeyField.autocorrectionType = UITextAutocorrectionTypeNo;
[_contentView addSubview:_xorKeyField];
[_xorKeyField mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(xorKeyLabel.mas_bottom).offset(8);
make.left.right.equalTo(_contentView);
make.height.offset(44);
}];
//
_playButton = [UIButton buttonWithType:UIButtonTypeSystem];
[_playButton setTitle:@"开始播放" forState:UIControlStateNormal];
@@ -183,7 +222,7 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
[_playButton addTarget:self action:@selector(playButtonTapped) forControlEvents:UIControlEventTouchUpInside];
[_contentView addSubview:_playButton];
[_playButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(_streamIdField.mas_bottom).offset(32);
make.top.equalTo(_xorKeyField.mas_bottom).offset(32);
make.left.right.equalTo(_contentView);
make.height.offset(50);
make.bottom.equalTo(_contentView);
@@ -201,9 +240,23 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
return;
}
NSString *xorKey = [_xorKeyField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (xorKey.length > 0) {
if (xorKey.length % 2 != 0) {
[self showAlertWithMessage:@"加密密钥必须为偶数长度"];
return;
}
NSCharacterSet *hexChars = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"];
if ([xorKey rangeOfCharacterFromSet:hexChars.invertedSet].location != NSNotFound) {
[self showAlertWithMessage:@"加密密钥只能包含十六进制字符 (0-9, a-f, A-F)"];
return;
}
}
SCPlayerConfig *config = [[SCPlayerConfig alloc] init];
config.protocol = _protocolSegment.selectedSegmentIndex == 0 ? SellyLiveMode_RTMP : SellyLiveMode_RTC;
config.streamId = streamId;
config.xorKey = xorKey.length > 0 ? xorKey : nil;
//
[config saveToUserDefaults];
@@ -306,6 +359,9 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
if (savedConfig.streamId && savedConfig.streamId.length > 0) {
_streamIdField.text = savedConfig.streamId;
}
// xorKey
_xorKeyField.text = savedConfig.xorKey ?: @"";
}
#pragma mark - UITextFieldDelegate
@@ -340,8 +396,9 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
UIViewAnimationCurve curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
//
CGRect textFieldFrame = [_streamIdField convertRect:_streamIdField.bounds toView:self];
//
UITextField *activeField = _xorKeyField.isFirstResponder ? _xorKeyField : _streamIdField;
CGRect textFieldFrame = [activeField convertRect:activeField.bounds toView:self];
CGFloat textFieldBottom = CGRectGetMaxY(textFieldFrame);
//

View File

@@ -15,10 +15,11 @@ typedef NS_OPTIONS(NSUInteger, AVSettingsFieldMask) {
AVSettingsFieldStreamId = 1 << 0, // Show Stream ID field
AVSettingsFieldNickname = 1 << 1, // Show Nickname field
AVSettingsFieldVideoParams = 1 << 2, // Show video params (codec, resolution, fps, bitrate)
AVSettingsFieldXorKey = 1 << 3, // Show XOR encryption key field
// Convenient combinations
AVSettingsFieldBasicPull = AVSettingsFieldStreamId | AVSettingsFieldNickname, // For pull page
AVSettingsFieldAll = AVSettingsFieldStreamId | AVSettingsFieldNickname | AVSettingsFieldVideoParams // For push page
AVSettingsFieldAll = AVSettingsFieldStreamId | AVSettingsFieldNickname | AVSettingsFieldVideoParams | AVSettingsFieldXorKey // For push page
};
@interface AVSettingsView : UIView

View File

@@ -21,6 +21,7 @@
@property (nonatomic, strong) UITextField *fpsField;
@property (nonatomic, strong) UITextField *maxBitrateField;
@property (nonatomic, strong) UITextField *minBitrateField;
@property (nonatomic, strong) UITextField *xorKeyField;
@property (nonatomic, strong) NSLayoutConstraint *containerCenterYConstraint;
@property (nonatomic, weak) UITextField *activeTextField; //
@@ -194,6 +195,37 @@
topOffset += 40 + spacing;
}
// XOR Key (after nickname, before video params)
if (self.fieldsMask & AVSettingsFieldXorKey) {
UILabel *xorKeyLabel = [[UILabel alloc] init];
xorKeyLabel.text = @"加密密钥 (Hex)";
xorKeyLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
[contentView addSubview:xorKeyLabel];
xorKeyLabel.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[xorKeyLabel.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:20],
[xorKeyLabel.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:topOffset]
]];
topOffset += 20;
_xorKeyField = [[UITextField alloc] init];
_xorKeyField.placeholder = @"如 AABBCCDD留空不加密";
_xorKeyField.borderStyle = UITextBorderStyleRoundedRect;
_xorKeyField.autocapitalizationType = UITextAutocapitalizationTypeNone;
_xorKeyField.autocorrectionType = UITextAutocorrectionTypeNo;
_xorKeyField.delegate = self;
_xorKeyField.returnKeyType = UIReturnKeyDone;
[contentView addSubview:_xorKeyField];
_xorKeyField.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[_xorKeyField.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:20],
[_xorKeyField.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor constant:-20],
[_xorKeyField.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:topOffset],
[_xorKeyField.heightAnchor constraintEqualToConstant:40]
]];
topOffset += 40 + spacing;
}
// Video parameters (conditional)
if (self.fieldsMask & AVSettingsFieldVideoParams) {
// Resolution
@@ -373,6 +405,14 @@
if (_minBitrateField) {
_minBitrateField.text = [NSString stringWithFormat:@"%ld", (long)(config.videoMinBitRate / 1000)];
}
if (_xorKeyField) {
// Load from config first, fallback to cached value
NSString *key = config.xorKey;
if (key.length == 0) {
key = [[NSUserDefaults standardUserDefaults] stringForKey:@"selly_xor_key_cache"];
}
_xorKeyField.text = key ?: @"";
}
self.frame = viewController.view.bounds;
self.alpha = 0;
@@ -428,6 +468,17 @@
self.tempConfig.videoMinBitRate = minBitrate * 1000; // Convert kbps to bps
}
if (_xorKeyField) {
NSString *key = [_xorKeyField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
self.tempConfig.xorKey = key.length > 0 ? key : nil;
// Cache for next time
if (key.length > 0) {
[[NSUserDefaults standardUserDefaults] setObject:key forKey:@"selly_xor_key_cache"];
} else {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:@"selly_xor_key_cache"];
}
}
if (self.callback) {
self.callback(self.tempConfig);
}

View File

@@ -38,6 +38,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign)SellyPlayerScalingMode scaleMode;
//token
@property (nonatomic, strong)NSString *token;
/// XOR 解密密钥 (十六进制字符串, 如 @"ABCDEF12")
/// 必须为偶数长度且仅包含 0-9, a-f, A-F传入非法值将抛出 NSInvalidArgumentException 导致 App 崩溃。
/// 传 nil 或空字符串表示不解密。
@property (nonatomic, copy, nullable)NSString *xorKey;
@end

View File

@@ -117,6 +117,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign)SellyLiveVideoConfiguration *videoConfig;
//token
@property (nonatomic, strong)NSString *token;
/// XOR 加密密钥 (十六进制字符串, 如 @"ABCDEF12")
/// 必须为偶数长度且仅包含 0-9, a-f, A-F传入非法值将抛出 NSInvalidArgumentException 导致 App 崩溃。
/// 传 nil 或空字符串表示不加密。
@property (nonatomic, copy, nullable)NSString *xorKey;
/// 是否启用 Center Stage人物居中默认 NO
/// 支持运行时动态切换,不支持的设备设置无效