rtmp、rtc推拉流支持加密
This commit is contained in:
@@ -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,89 +32,24 @@ 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);
|
||||
|
||||
// 保存登录状态
|
||||
[self saveLoginStateWithUsername:username];
|
||||
|
||||
// 可以根据需要保存服务器返回的其他信息(如 token、userId 等)
|
||||
// 例如:
|
||||
// if (responseObject[@"token"]) {
|
||||
// [[NSUserDefaults standardUserDefaults] setObject:responseObject[@"token"] forKey:@"AVUserToken"];
|
||||
// }
|
||||
|
||||
// 回调成功
|
||||
if (completion) {
|
||||
completion(YES, nil);
|
||||
|
||||
[[AVApiService shared] loginWithUsername:username password:password
|
||||
success:^(id responseObject) {
|
||||
[self saveLoginStateWithUsername:username];
|
||||
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);
|
||||
}
|
||||
}];
|
||||
failure:^(NSError *error, NSString *serverMessage) {
|
||||
NSString *msg = serverMessage ?: [NSString stringWithFormat:@"网络请求失败: %@", error.localizedDescription];
|
||||
if (completion) completion(NO, msg);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)saveLoginStateWithUsername:(NSString *)username {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}];
|
||||
}
|
||||
@@ -154,6 +168,9 @@
|
||||
|
||||
// 设置协议标签
|
||||
_protocolLabel.text = model.play_protocol.uppercaseString;
|
||||
|
||||
// 加密图标
|
||||
_encryptIcon.hidden = (model.xorKey.length == 0);
|
||||
|
||||
// 使用 SDWebImage 加载预览图
|
||||
if (model.preview_image.length > 0) {
|
||||
@@ -177,6 +194,7 @@
|
||||
_nameLabel.text = @"";
|
||||
_durationLabel.text = @"";
|
||||
_protocolLabel.text = @"";
|
||||
_encryptIcon.hidden = YES;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -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;
|
||||
|
||||
// ============ 工厂方法 ============
|
||||
|
||||
/// 创建默认配置
|
||||
|
||||
@@ -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,7 +458,8 @@
|
||||
|
||||
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) {
|
||||
error = [self.livePusher startLiveWithStreamId: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];
|
||||
|
||||
39
Example/SellyCloudSDK/Network/AVApiService.h
Normal file
39
Example/SellyCloudSDK/Network/AVApiService.h
Normal 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
|
||||
101
Example/SellyCloudSDK/Network/AVApiService.m
Normal file
101
Example/SellyCloudSDK/Network/AVApiService.m
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
|
||||
@implementation AVLiveStreamModel
|
||||
|
||||
+ (NSDictionary *)modelCustomPropertyMapper {
|
||||
return @{
|
||||
@"xorKey" : @"xor_key"
|
||||
};
|
||||
}
|
||||
|
||||
- (NSString *)displayName {
|
||||
return self.stream.length > 0 ? self.stream : @"未知流";
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -39,7 +45,10 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId";
|
||||
// 加载 streamId
|
||||
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,10 +240,24 @@ 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);
|
||||
|
||||
// 计算需要的偏移量
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user