# EZOpenSDK iOS 播放器（预览/回放/录制）

## ⛔ 强制约束（违反即为 Bug）

以下规则在生成播放器相关代码时**必须严格遵守**，不得以任何理由偏离：

### 1. 播放器必须定义为强引用属性

- **禁止** 将 EZPlayer 定义为局部变量（会被 ARC 释放导致无法播放）
- **必须** 定义为 `@property (nonatomic, strong) EZPlayer *player`

```objc
// ✅ 正确
@property (nonatomic, strong) EZPlayer *player;

// ❌ 错误 — 局部变量会被 ARC 释放
- (void)startPreview {
    EZPlayer *player = [EZOpenSDK createPlayerWithDeviceSerial:serial cameraNo:cameraNo];
    [player startRealPlay]; // 播放器可能已被释放
}
```

### 2. 清晰度列表必须从播放器实例方法获取

- **禁止** 从 `EZCameraInfo.videoQualityInfos` 获取清晰度列表（该属性返回的是设备注册时的静态数据，可能不准确，会出现重复清晰度）
- **必须** 在取流成功后通过 `[player getDeviceDetailInfo:]` 获取 `EZDeviceDetailPublicInfo`，从 `info.videoQualityInfos` 读取

```objc
// ✅ 正确 — 取流成功后从播放器实例方法获取
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        [self.player getDeviceDetailInfo:^(EZDeviceDetailPublicInfo *info) {
            NSArray *qualityInfos = info.videoQualityInfos; // 准确的清晰度列表
            NSInteger currentLevel = info.videoLevel;
        }];
    }
}

// ❌ 错误 — 从 EZCameraInfo 获取（静态数据，可能有重复）
NSArray *qualityInfos = self.cameraInfo.videoQualityInfos;
```

### 3. 清晰度选择面板必须包含"自动"选项

- 如果设备支持自动清晰度（`[EZBusinessTool isSupportDeviceAutoVideolevel:deviceInfo cameraInfo:cameraInfo]` 返回 YES），清晰度选择列表**顶部必须添加"AUTO"选项**
- 用户选择"AUTO"后，调用 `[player enableDeviceAutoVideoLevel]` 开启网络检测，然后重新取流
- 自动模式下，清晰度按钮显示"AUTO(当前清晰度名称)"

### 4. 云台控制必须使用 controlPTZMix

- **禁止** 使用 `[EZOpenSDK controlPTZ:cameraNo:command:action:speed:result:]`（旧接口，参数名不同）
- **必须** 使用 `[EZOpenSDK controlPTZMix:cameraNo:command:action:newSpeed:result:]`（推荐接口）

```objc
// ✅ 正确 — 使用 controlPTZMix，参数名为 newSpeed
[EZOpenSDK controlPTZMix:deviceSerial cameraNo:cameraNo command:EZPTZCommandLeft action:EZPTZActionStart newSpeed:4 result:^(NSError *error) {
}];

// ❌ 错误 — controlPTZ 是旧接口，参数名为 speed
[EZOpenSDK controlPTZ:deviceSerial cameraNo:cameraNo command:command action:action speed:4 result:^(NSError *error) {
}];
```

### 5. 验证码必须从本地缓存读取

- 预览/回放页面启动时，**必须** 从本地缓存（NSUserDefaults）读取验证码，而不是仅依赖页面传递
- 缓存 key 格式：`ez_verify_code_` + deviceSerial
- 预览页面用户输入验证码且取流成功后缓存到本地，回放页面应直接读取该缓存，避免重复弹框
- 读取优先级：本地缓存 > 页面传入值

```objc
// ✅ 正确 — 从本地缓存读取验证码
NSString *key = [NSString stringWithFormat:@"ez_verify_code_%@", deviceSerial];
NSString *cachedCode = [[NSUserDefaults standardUserDefaults] stringForKey:key];
if (cachedCode.length > 0) {
    [self.player setPlayVerifyCode:cachedCode];
}

// ❌ 错误 — 仅依赖页面传递验证码（预览页面输入的验证码可能未传递到回放页面）
[self.player setPlayVerifyCode:self.verifyCodeFromPreviousPage];
```

### 6. 释放播放器必须使用类方法

- **禁止** 调用播放器实例的 release 方法（不存在）
- **必须** 使用 `[EZOpenSDK releasePlayer:player]` 类方法释放

```objc
// ✅ 正确
[EZOpenSDK releasePlayer:self.player];
self.player = nil;

// ❌ 错误 — 不存在实例方法 release
[self.player release]; // 编译错误或 ARC 下无效
```

### 7. 录制停止必须使用带回调的方法

- **禁止** 在 `stopLocalRecordExt:` 回调之前读取/操作录制文件（文件可能未写完）
- **必须** 在 `stopLocalRecordExt:^(BOOL ret){}` 的 block 回调中确认录制完成后再转存相册

```objc
// ✅ 正确 — 在回调中确认完成后保存
[self.player stopLocalRecordExt:^(BOOL ret) {
    if (ret) {
        [self saveVideoFileToAlbum:filePath];
    }
}];

// ❌ 错误 — 停止后立即操作文件（文件可能不完整）
[self.player stopLocalRecordExt:nil];
[self saveVideoFileToAlbum:filePath]; // 视频可能损坏
```

### 8. 电子放大 rect 必须传实例变量地址

- **禁止** 传局部变量的地址给 `setDisplayRegionEx:streamId:`（SDK 内部持续引用该指针，局部变量出栈后指针悬空）
- **必须** 使用实例变量（ivar）的地址

```objc
// ✅ 正确 — 使用实例变量
@property (nonatomic, assign) CGRect zoomRectValue;
[_player setDisplayRegionEx:&_zoomRectValue streamId:0];

// ❌ 错误 — 局部变量地址（出栈后悬空）
CGRect rect = CGRectMake(0.2, 0.2, 0.6, 0.6);
[_player setDisplayRegionEx:&rect streamId:0]; // 崩溃或画面异常
```

### 9. 禁止自定义消息常量

- **禁止** 定义 `#define PLAYER_REALPLAY_START 1` 或 `static const NSInteger kPlaySuccess = 1` 等自定义数字常量
- **必须** 直接使用 SDK 在 `EZPlayer.h` 中定义的 `EZMessageCode` 枚举值
- **必须** import：`#import <EZOpenSDKFramework/EZPlayer.h>`

```objc
// ✅ 正确 — 直接使用 SDK 枚举常量
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        // 预览成功
    } else if (messageCode == PLAYER_VOICE_TALK_START) {
        // 对讲成功
    }
}

// ❌ 错误 — 禁止自定义常量值
#define MSG_PLAY_SUCCESS 1
#define MSG_TALK_SUCCESS 3  // 错误！对讲是 4 不是 3
if (messageCode == MSG_PLAY_SUCCESS) { ... }
```

SDK 定义的完整 `EZMessageCode` 枚举（定义在 `EZPlayer.h`）：

| 枚举值 | 数值 | 含义 |
|--------|------|------|
| PLAYER_REALPLAY_START | 1 | 直播开始 |
| PLAYER_VIDEOLEVEL_CHANGE | 2 | 直播流清晰度切换中 |
| PLAYER_STREAM_RECONNECT | 3 | 直播流取流正在重连 |
| PLAYER_VOICE_TALK_START | 4 | 对讲开始 |
| PLAYER_VOICE_TALK_END | 5 | 对讲结束 |
| PLAYER_STREAM_START | 10 | 录像取流开始 |
| PLAYER_PLAYBACK_START | 11 | 录像回放开始播放 |
| PLAYER_PLAYBACK_STOP | 12 | 录像回放结束播放 |
| PLAYER_PLAYBACK_FINISHED | 13 | 录像回放被用户强制中断 |
| PLAYER_PLAYBACK_PAUSE | 14 | 录像回放暂停 |
| PLAYER_NET_CHANGED | 21 | 播放器检测到wifi变换过 |
| PLAYER_NO_NETWORK | 22 | 播放器检测到无网络 |
| PLAYER_CLOUD_IFRAME_CHANGED | 23 | 云存储快放时全帧切抽帧 |
| PLAYER_VIDEO_LEVEL_AUTO_IMPROVE | 25 | 网络好，建议升档 |
| PLAYER_VIDEO_LEVEL_AUTO_REDUCE | 26 | 网络差，建议降档 |

### 10. 云录像封面必须使用 POST 请求加载

- **禁止** 使用 GET 请求（`NSData dataWithContentsOfURL:` 或默认 GET 的 NSURLSession）加载云录像封面
- **必须** 使用 POST 请求加载 `EZCloudRecordFile.coverPic` URL
- **必须** 在请求 body 中拼接 `coverPicURL&x=200&decodekey=验证码`（加密设备解密封面必需）

```objc
// ✅ 正确 — 使用 POST 请求加载云录像封面
NSURL *url = [NSURL URLWithString:cloudFile.coverPic];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"POST";
NSString *verifyCode = [PreviewModule cachedVerifyCodeForDevice:deviceSerial] ?: @"";
NSString *body = [NSString stringWithFormat:@"%@&x=%d&decodekey=%@", cloudFile.coverPic, 200, verifyCode];
request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
    UIImage *image = [UIImage imageWithData:data];
}];
[task resume];

// ❌ 错误 — GET 请求无法获取封面（服务端要求 POST）
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:cloudFile.coverPic]];
```

### 11. SD 卡录像封面必须通过 EZRecordCoverFetcherManager 获取

- **禁止** 对 SD 卡录像使用 URL 方式加载封面（SD 卡录像没有 coverPic URL）
- **必须** 通过 `EZRecordCoverFetcherManager` 与设备建立连接后提取封面
- **必须** 在 `viewDidLoad` 中调用 `initFetcherWithDeviceSerial:cameraNo:` 提前建链
- **必须** 查询到 SD 卡录像后直接调用 `requestRecordCover:`（无需等待初始化回调）
- **必须** 传给 `requestRecordCover:` 的录像列表顺序与 UI 展示顺序保持一致（SDK 按传入顺序逐个提取封面，确保用户看到的是从列表第一个开始加载）
- **必须** 为每条记录设置 `seq` 索引（从 0 开始递增）
- **必须** 在 dealloc 或页面退出时调用 `stopFetcher` 释放资源

```objc
// ✅ 正确 — viewDidLoad 中初始化封面提取器
- (void)viewDidLoad {
    [[EZRecordCoverFetcherManager sharedInstance] initFetcherWithDeviceSerial:serial cameraNo:cameraNo];
    [EZRecordCoverFetcherManager sharedInstance].fetcherDelegate = self;
}

// 查询到 SD 卡录像后，按 UI 展示顺序传入请求封面
for (int i = 0; i < records.count; i++) {
    records[i].seq = i;
}
[[EZRecordCoverFetcherManager sharedInstance] requestRecordCover:records];

// 代理回调 — 通过 seq 匹配录像对象，设置封面
- (void)onGetCoverSuccess:(int)seq data:(NSData *)data {
    // 遍历匹配 file.seq == seq 的录像，设置 imageData 并刷新对应 cell
}

// ❌ 错误 — SD 卡录像没有 coverPic URL，不能用网络请求加载
NSURL *url = [NSURL URLWithString:deviceFile.coverPic]; // 属性不存在

// ❌ 错误 — 在查询到录像时才初始化 Fetcher 再立即请求（建链可能未完成）
- (void)handleSearchResult:(NSArray *)records {
    [[EZRecordCoverFetcherManager sharedInstance] initFetcherWithDeviceSerial:serial cameraNo:cameraNo];
    [[EZRecordCoverFetcherManager sharedInstance] requestRecordCover:records]; // 可能失败
}
```

### 12. 录像下载的 PS 文件必须转码为 MP4 后再保存相册

- **禁止** 将下载得到的 `.ps` 文件直接通过 `PHPhotoLibrary` 保存到相册（系统不识别 PS 格式，会保存失败）
- **必须** 先用 `EZVideoTransformer` 将 PS 文件转码为 MP4，再将 MP4 保存到相册
- **必须** 转码时传入设备验证码（加密设备解密需要）
- **必须** 在保存完成后清理 PS 和 MP4 临时文件

```objc
// ✅ 正确 — 下载完成后先转码再保存
[task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {
    if (statusCode == EZRecordDownloaderStatusFinish) {
        NSString *mp4Path = [psPath stringByReplacingOccurrencesOfString:@".ps" withString:@".mp4"];
        [EZVideoTransformer videoTransFormerPSPath:psPath
                                           toPath:mp4Path
                                             type:EZVideoTransformerTypeMP4
                                          withKey:verifyCode
                                        streamId:0
                             streamStateVariable:NO
                                       succBlock:^{
            // 转码成功，保存 MP4 到相册
            [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ ... }];
        } processBlock:^(int rate) {} failBlock:^(int errCode) {}];
    }
} failed:^(NSError *error) {}];

// ❌ 错误 — 直接保存 PS 文件到相册（系统不识别，必定失败）
[task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {
    if (statusCode == EZRecordDownloaderStatusFinish) {
        NSURL *fileURL = [NSURL fileURLWithPath:psPath];
        [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
            [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:fileURL]; // 失败！
        }];
    }
}];
```

---

## 预览页面 UI 标准组件

实现预览页面时，**必须包含以下所有控件**，缺少任何一个都视为功能不完整：

| 序号 | 控件 | 功能说明 | 对应 SDK 方法 | 显示条件 |
|------|------|----------|--------------|----------|
| 1 | **播放/暂停** | 停止或恢复实时预览 | `stopRealPlay` / `startRealPlay` | 始终显示 |
| 2 | **声音开关** | 开启或关闭预览声音（默认开启） | `openSound` / `closeSound` | 始终显示 |
| 3 | **清晰度选择** | 切换视频清晰度（含自动选项） | `setVideoLevel` + 重新取流 | 始终显示 |
| 4 | **截图** | 抓取当前画面并保存到相册 | `capturePicture:` | 播放中显示 |
| 5 | **录制** | 录制当前视频流并保存到相册（显示录制计时） | `startLocalRecordWithPathExt:` / `stopLocalRecordExt:` | 播放中显示 |
| 6 | **云台控制** | 方向控制（上下左右），按下/松开触发 Start/Stop | `controlPTZMix:` | 设备支持 PTZ 时显示 |
| 7 | **对讲** | 与设备进行语音对讲（全双工/半双工） | 参见 `ezopensdk-talk.md` | 设备支持对讲时显示 |
| 8 | **全屏** | 切换全屏/退出全屏（紧凑模式竖屏全屏，非紧凑模式横屏全屏） | 参见下方"竖屏全屏/横屏全屏"章节 | 始终显示（浮动在播放器右下角） |

### 交互规则

1. **播放/暂停**：播放中显示暂停图标，停止时显示播放图标。点击暂停时，先在子线程调用 `capturePicture:` 截取当前画面，将截图作为封面覆盖在播放器视图上方，然后再调用 `stopRealPlay` 停止取流，实现画面冻结效果。用户再次点击播放，调用 `startRealPlay` 恢复取流，取流成功（收到 `PLAYER_REALPLAY_START`）后移除封面。
2. **声音开关**：取流成功后声音默认开启。对讲期间非国标设备自动关闭预览声音，对讲结束后恢复。
3. **清晰度选择**：弹出 ActionSheet 清晰度列表（从 `[player getDeviceDetailInfo:]` 获取）。支持自动清晰度的设备列表顶部显示"AUTO"选项。切换流程：`setVideoLevel` → `stopRealPlay` → `startRealPlay`。
4. **截图**：子线程调用 `capturePicture:100`，通过 PHPhotoLibrary 保存到相册，保存后 Toast 提示。
5. **录制**：开始录制后显示红色计时器，停止后在 block 回调中转存到相册。录制期间禁止切换清晰度和自动清晰度。
6. **云台控制**：Touch Down 发送 `EZPTZActionStart`，Touch Up 发送 `EZPTZActionStop`。通过 `[EZBusinessTool isSupportPTZ:cameraInfo:]` 判断是否显示。预览未成功时禁用。
7. **对讲**：通过 `[EZBusinessTool isSupportTalk:cameraInfo:]` 判断是否支持及对讲类型。对讲前需申请麦克风权限。
8. **全屏**：全屏按钮**浮动在播放器画面右下角**（半透明黑色圆形背景+白色图标），不放在工具栏中（避免空间冲突）。紧凑模式设备（竖屏比例如 9:16）点击后竖屏全屏（不旋转屏幕），非紧凑模式设备（如 16:9）点击后横屏全屏（旋转屏幕）。全屏状态下点击退出全屏。详见下方"竖屏全屏/横屏全屏"章节。

---

## 回放页面 UI 标准组件

实现回放页面时，**必须包含以下所有控件**，缺少任何一个都视为功能不完整：

| 序号 | 控件 | 功能说明 | 对应 SDK 方法 | 显示条件 |
|------|------|----------|--------------|----------|
| 1 | **暂停/恢复** | 暂停或恢复回放 | `pausePlayback` / `resumePlayback` | 始终显示 |
| 2 | **声音开关** | 开启或关闭回放声音（默认开启） | `openSound` / `closeSound` | 始终显示 |
| 3 | **进度条** | 显示当前播放进度，支持拖拽 seek | `seekPlayback:` / `getOSDTime` | 始终显示 |
| 4 | **倍速选择** | 切换回放倍速（根据设备能力集动态渲染选项） | `setPlaybackRate:mode:` | 设备支持倍速时显示 |
| 5 | **下载** | 下载当前录像片段到相册 | `EZCloudRecordDownloadTask` / `EZDeviceRecordDownloadTask` | 始终显示 |
| 6 | **日期选择** | 切换查询日期 | 重新调用 `searchRecordFileFromCloud/Device` | 始终显示 |
| 7 | **云存储/SD 卡切换** | 切换录像来源类型 | — | 始终显示 |
| 8 | **录像宫格列表** | 展示录像片段（封面+时间+时长），点击播放 | — | 始终显示 |

### 交互规则

1. **暂停/恢复**：播放中显示暂停图标，暂停时显示播放图标。
2. **声音开关**：默认开启。点击切换开/关状态并更新图标。
3. **进度条**：基于当前片段的起止时间计算进度，通过 OSD 定时器每秒更新。拖拽松开后调用 `seekPlayback:`。
4. **倍速选择**：
   - **显示条件**：`[EZBusinessTool isSupportPlaybackRate:deviceInfo cameraInfo:cameraInfo] == YES` 且播放中时显示倍速按钮
   - **SD 卡录像倍速选项动态渲染**：SD 卡录像的倍速选项根据设备能力集动态渲染，通过 `[EZBusinessTool getSDCardPlaybackRates:deviceInfo cameraInfo:cameraInfo streamFetchType:streamFetchType]` 获取设备支持的倍速列表（返回 nil 表示不支持），UI 仅展示设备实际支持的倍速选项（不再写死固定选项）
   - **云录像倍速选项**：云录像倍速选项固定为 `[0.5x, 1x, 2x, 4x, 8x, 16x, 32x]`
   - **UI 交互**：弹出 ActionSheet，选择后调用 `setPlaybackRate:mode:`。UI 使用底部弹框（胶囊形选项按钮，选中纯黑填充+白字，未选中透明+描边），选择后即时生效
   - **内网直连倍速限制判断**：已内置在 `getSDCardPlaybackRates` 逻辑中（内网直连 + 支持内网直连倍速 → 返回 1、4、8）
   - **切换结果**：`setPlaybackRate:mode:` 返回 YES 成功（更新按钮文本为当前倍率），返回 NO 失败（Toast 提示）
   - **状态重置**：停止回放或切换录像类型时，重置倍速为 1x
5. **下载**：下载当前选中的录像片段到相册。云存储录像使用 `EZCloudRecordDownloadTask`，SD 卡录像使用 `EZDeviceRecordDownloadTask`。下载到沙盒路径，在完成回调中通过 PHPhotoLibrary 转存到系统相册。下载期间在录像封面右下角显示进度百分比（云存储录像通过 `setDownloadCallBackWithDownloadSize:` 获取进度，SD 卡录像不支持进度回调）。页面退出后下载继续在后台执行（task 由单例持有），完成/失败回调中必须调用 `stopDownloadTask:` 释放 taskID。SDK 通过 `taskID` 判断重复下载（相同 taskID 返回 -2），双目设备需调用 `[task setMultiChannelDevice:YES]`。
6. **日期选择**：弹出 UIDatePicker，选择后重新查询录像。不能选择未来日期。
7. **云存储/SD 卡切换**：切换时更新录像列表数据源和 UI。切换后重新查询对应类型的录像并刷新列表。
8. **录像列表**：每行 3 个宫格，封面 16:9 比例，显示开始时间和时长。云存储录像封面通过 `EZCloudRecordFile.coverPic` 属性获取 URL，**必须使用 POST 请求加载**（GET 无法获取），加密设备需在 body 中拼接 `&decodekey=验证码`（参见强制约束第 10 条）。SD 卡录像封面通过 `EZRecordCoverFetcherManager` 获取。点击片段开始/切换回放。
9. **视频容器比例**：视频播放区域**必须**根据设备宽高比设置（通过 `[deviceInfo getSupportValue:16]` 获取），默认 16:9。与预览页面保持一致。
10. **电子放大**：回放页面**必须**实现电子放大功能（双指缩放），使用 `setDisplayRegionEx:streamId:` 设置显示区域（参见强制约束第 8 条，rect 必须传实例变量地址）。缩小到全画面（{0,0,1,1}）时自动恢复。

---

## 关键方法签名速查（避免猜测参数名）

以下列出容易出错的方法签名，标注参数名和类型：

```objc
// ==================== EZOpenSDK 类方法 ====================
+ (void)initLibWithAppKey:(NSString *)appKey;
+ (void)setAccessToken:(NSString *)accessToken;
+ (void)setDebugLogEnable:(BOOL)enable;
+ (void)setDebugStreamEnable:(BOOL)enable;
+ (EZPlayer *)createPlayerWithDeviceSerial:(NSString *)deviceSerial cameraNo:(NSInteger)cameraNo;
+ (void)releasePlayer:(EZPlayer *)player;

// 清晰度设置（异步，带 completion）
+ (void)setVideoLevel:(NSString *)deviceSerial cameraNo:(NSInteger)cameraNo videoLevel:(int)videoLevel completion:(void(^)(NSError *error))completion;

// 自动清晰度设置（非强制，不断流）
+ (void)setVideoLevelAuto:(NSString *)deviceSerial cameraNo:(NSInteger)cameraNo videoLevel:(NSInteger)videoLevel completion:(void(^)(NSError *error))completion;

// 云台控制（推荐）— 注意参数名是 newSpeed 不是 speed
+ (void)controlPTZMix:(NSString *)deviceSerial cameraNo:(NSInteger)cameraNo command:(EZPTZCommand)command action:(EZPTZAction)action newSpeed:(int)speed result:(void(^)(NSError *error))result;

// 设备探测 — 注意需要 deviceType 参数
+ (void)probeDeviceInfo:(NSString *)deviceSerial deviceType:(NSString *)deviceType completion:(void(^)(EZProbeDeviceInfo *info, NSError *error))completion;

// ==================== EZPlayer 实例方法 ====================
- (void)setPlayerView:(UIView *)playerView;
- (void)setPlayVerifyCode:(NSString *)verifyCode;
- (void)startRealPlay;
- (void)stopRealPlay;
- (void)startPlaybackFromCloud:(EZCloudRecordFile *)file;
- (void)startPlaybackFromDevice:(EZDeviceRecordFile *)file;
- (void)stopPlayback;
- (BOOL)pausePlayback;
- (BOOL)resumePlayback;
- (void)seekPlayback:(NSDate *)time;
- (BOOL)setPlaybackRate:(int)rate mode:(int)mode;
- (BOOL)openSound;
- (BOOL)closeSound;
- (UIImage *)capturePicture:(NSInteger)quality;  // ⚠️ 需要 quality 参数（0~100）
- (BOOL)startLocalRecordWithPathExt:(NSString *)path;
- (void)stopLocalRecordExt:(void(^)(BOOL ret))complete;  // ⚠️ 带 block 回调
- (void)startVoiceTalk;
- (void)stopVoiceTalk;
- (BOOL)audioTalkPressed:(BOOL)pressed;
- (void)enableDeviceAutoVideoLevel;  // 开启自动清晰度网络检测
- (void)getDeviceDetailInfo:(void(^)(EZDeviceDetailPublicInfo *info))completion;  // ⚠️ 播放器实例方法，非类方法
- (NSDate *)getOSDTime;
- (void)setDisplayRegionEx:(CGRect *)rect streamId:(int)streamId;

// ==================== 录像下载（EZRecordDownloadTask 及子类） ====================
// 云存储录像下载
- (instancetype)initTaskWithID:(NSInteger)taskID cloudRecordFile:(EZCloudRecordFile *)cloudFile verifyCode:(NSString *)verifyCode savePath:(NSString *)savePath;
// SD 卡录像下载（异步初始化，completion 中拿到 task 后设置回调再 add）
- (instancetype)initTaskWithID:(NSInteger)taskID DeviceRecordFileInfo:(EZDeviceRecordFile *)deviceFile deviceSerial:(NSString *)serial cameraNo:(NSInteger)cameraNo verifyCode:(NSString *)verifyCode savePath:(NSString *)path completion:(void(^)(EZDeviceRecordDownloadTask *task))completion;
// 下载回调（⚠️ finshed 和 failed 必须同时传，缺 failed: 编译报错）
- (void)setDownloadCallBackWithFinshed:(void(^)(EZRecordDownloaderStatus statusCode))finshed failed:(void(^)(NSError *error))failed;
// 云存储下载进度回调（SD 卡不支持）
- (void)setDownloadCallBackWithDownloadSize:(void(^)(NSUInteger downloadSize))downloading;
// 双目设备需调用
- (void)setMultiChannelDevice:(BOOL)isMulti;

// ==================== EZRecordDownloader（下载管理器单例） ====================
+ (instancetype)shareInstane;
- (void)addDownloadTask:(EZRecordDownloadTask *)task;
- (void)stopDownloadTask:(EZRecordDownloadTask *)task;
@property (nonatomic, strong, readonly) NSMutableArray *tasks;  // 当前所有下载任务

// ==================== EZPlayerDelegate ====================
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode;
- (void)player:(EZPlayer *)player didPlayFailed:(NSError *)error;
- (void)player:(EZPlayer *)player didReceivedDevicePtzAngleInfo:(EZDevicePtzAngleInfo *)info;

// ==================== 消息码常量（EZMessageCode 枚举，定义在 EZPlayer.h） ====================
// PLAYER_REALPLAY_START = 1         直播开始
// PLAYER_VIDEOLEVEL_CHANGE = 2      直播流清晰度切换中
// PLAYER_STREAM_RECONNECT = 3       直播流取流正在重连
// PLAYER_VOICE_TALK_START = 4       对讲开始
// PLAYER_VOICE_TALK_END = 5         对讲结束
// PLAYER_STREAM_START = 10          录像取流开始
// PLAYER_PLAYBACK_START = 11        录像回放开始播放
// PLAYER_PLAYBACK_STOP = 12         录像回放结束播放
// PLAYER_PLAYBACK_FINISHED = 13     录像回放被用户强制中断
// PLAYER_PLAYBACK_PAUSE = 14        录像回放暂停
// PLAYER_NET_CHANGED = 21           播放器检测到wifi变换过
// PLAYER_NO_NETWORK = 22            播放器检测到无网络
// PLAYER_CLOUD_IFRAME_CHANGED = 23  云存储快放时，全帧快放切换到抽帧快放
// PLAYER_VIDEO_LEVEL_AUTO_IMPROVE = 25  网络好，建议升档（需开启 enableDeviceAutoVideoLevel）
// PLAYER_VIDEO_LEVEL_AUTO_REDUCE = 26   网络差，建议降档（需开启 enableDeviceAutoVideoLevel）

// ==================== 错误码 ====================
// 400035 = EZ_SDK_NEED_VALIDATECODE      需要验证码
// 400036 = EZ_SDK_VALIDATECODE_NOT_MATCH  验证码错误
```

---

## 播放器模块 import 参考

```objc
#import <EZOpenSDKFramework/EZOpenSDK.h>
#import <EZOpenSDKFramework/EZPlayer.h>
#import <EZOpenSDKFramework/EZConstants.h>          // EZPTZCommand、EZPTZAction 等枚举定义
#import <EZOpenSDKFramework/EZDeviceInfo.h>
#import <EZOpenSDKFramework/EZCameraInfo.h>
#import <EZOpenSDKFramework/EZDeviceDetailPublicInfo.h>
#import <EZOpenSDKFramework/EZVideoQualityInfo.h>
#import <EZOpenSDKFramework/EZCloudRecordFile.h>
#import <EZOpenSDKFramework/EZDeviceRecordFile.h>
#import <EZOpenSDKFramework/EZPlayWaterMarkConfig.h>
#import <EZOpenSDKFramework/EZDevicePtzAngleInfo.h>
#import <EZOpenSDKFramework/EZPMPlayPrivateTokenInfo.h>
#import "EZRecordCoverFetcherManager.h"
```

## 一、播放器创建与释放

### 创建播放器
```objc
// 通过设备序列号和通道号创建
EZPlayer *player = [EZOpenSDK createPlayerWithDeviceSerial:deviceSerial cameraNo:cameraNo];

// 通过 URL 创建
EZPlayer *player = [EZOpenSDK createPlayerWithUrl:url];
```

### 释放播放器
```objc
// 释放播放器必须使用 EZOpenSDK 类方法，不是实例方法
[EZOpenSDK releasePlayer:player];
```

**重要**：
- 播放器必须定义为属性（`@property (nonatomic, strong)`），定义成局部变量会导致无法播放
- 同一设备的不同功能（直播/回放/对讲）需要分别创建独立的播放器
- 释放播放器使用 `[EZOpenSDK releasePlayer:player]` 类方法，不要调用实例方法

## 二、取流方式配置

SDK 支持三种取流方式，优先顺序：内网直连 > P2P > 流媒体。

### 内网直连开关（默认开启）
```objc
[EZOpenSDK enableDirectInner:YES];
```

### P2P 开关（默认关闭）
```objc
[EZOpenSDK enableP2P:YES];
```

### 流媒体开关（默认开启，慎用关闭）
```objc
[EZOpenSDK enableVTDU:YES];
```

### 查看取流方式
```objc
int type = [player getStreamFetchType]; // 耗时操作，子线程调用
// 0-流媒体，1-P2P，2-内网直连，4-云存储回放
```

## 三、直播预览

### 完整流程
```objc
@interface EZRealPlayViewController ()<EZPlayerDelegate>
@property (nonatomic, strong) EZPlayer *player; // 必须用属性持有
@end

@implementation EZRealPlayViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 1. 创建播放器
    _player = [EZOpenSDK createPlayerWithDeviceSerial:deviceSerial cameraNo:cameraNo];
    // 2. 设置代理
    _player.delegate = self;
    // 3. 设置验证码（加密设备需要）
    [_player setPlayVerifyCode:verifyCode];
    // 4. 设置播放视图
    [_player setPlayerView:self.playerView];
    // 5. 开始直播
    [_player startRealPlay];
}

- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];
    [_player stopRealPlay];
}

- (void)dealloc {
    [EZOpenSDK releasePlayer:_player];
}

#pragma mark - EZPlayerDelegate

- (void)player:(EZPlayer *)player didPlayFailed:(NSError *)error {
    if (error.code == EZ_SDK_NEED_VALIDATECODE) {
        // 需要验证码
    } else if (error.code == EZ_SDK_VALIDATECODE_NOT_MATCH) {
        // 验证码错误
    }
}

- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        // 预览播放成功
    }
}

@end
```

### 验证码管理（加密设备）

加密设备（`isEncrypt == YES`）取流前必须调用 `setPlayVerifyCode:` 设置验证码。验证码的完整生命周期管理策略如下：

**获取验证码的优先级**：
1. 优先从本地缓存读取（NSUserDefaults，key 格式：`ez_verify_code_{deviceSerial}`）
2. 无缓存时等待播放器回调 `EZ_SDK_NEED_VALIDATECODE`（400035），弹出输入框让用户输入

**缓存时机**：
- 用户输入验证码后先暂存，调用 `setPlayVerifyCode:` 并重新取流
- 取流成功（收到 `PLAYER_REALPLAY_START`）后，将验证码持久化到 NSUserDefaults
- 只有取流成功才缓存，确保缓存的验证码是正确的

**使用场景**：
- 预览取流：`startRealPlay` 前设置
- 回放取流：`startPlaybackFromCloud:` / `startPlaybackFromDevice:` 前设置
- 对讲取流：`startVoiceTalk` 前设置（如需要）

```objc
// 缓存验证码（取流成功后调用）
NSString *key = [NSString stringWithFormat:@"ez_verify_code_%@", deviceSerial];
[[NSUserDefaults standardUserDefaults] setObject:verifyCode forKey:key];

// 读取缓存验证码（创建播放器后、取流前调用）
NSString *cachedCode = [[NSUserDefaults standardUserDefaults] stringForKey:key];
if (cachedCode.length > 0) {
    [player setPlayVerifyCode:cachedCode];
}
```

**注意**：
- `EZDeviceInfo` 对象不包含 `deviceVerifyCode` 属性，验证码只能通过用户输入或本地缓存获取
- 验证码错误（400036）时应清除缓存，避免下次自动设置错误验证码
- 预览和回放使用独立播放器，但共享同一设备的验证码缓存

### 播放器窗口大小设置
方法一（推荐）：通过 `[deviceInfo getSupportValue:16]` 获取设备宽高比，动态设置
```objc
// 获取设备支持的宽高比（如 "16-9"、"4-3"、"1-1"）
NSString *ratioStr = [self.deviceInfo getSupportValue:16];
CGFloat playerHeight = screenWidth * 9.0 / 16.0; // 默认 16:9
if (ratioStr && [ratioStr containsString:@"-"]) {
    NSArray *parts = [ratioStr componentsSeparatedByString:@"-"];
    if (parts.count == 2) {
        CGFloat w = [parts[0] floatValue];
        CGFloat h = [parts[1] floatValue];
        if (w > 0 && h > 0) {
            playerHeight = screenWidth * h / w;
        }
    }
}
// 双目设备：两个 playerView 上下排列，各使用上面计算的 playerHeight
```
方法二：固定 16:9 比例（不推荐，无法适配非标比例设备）
方法三：实现代理 `player:didReceivedDisplayHeight:displayWidth:` 获取画面宽高
方法四：取流成功后调用 `getDeviceDetailInfo:` 获取 videoWidth/videoHeight

### 电池设备注意事项

电池设备取流 1 分钟后会进入休眠。需创建定时器每隔 50 秒调用平台接口唤醒设备：
```objc
int batteryType = [deviceInfo getSupportInt:322]; // 0-不使用电池，1-一块，2-两块
// batteryType > 0 时需要定时唤醒
```

## 四、录像回放

### 录像查询
```objc
// 查询云存储录像
[EZOpenSDK searchRecordFileFromCloud:deviceSerial cameraNo:cameraNo beginTime:beginTime endTime:endTime completion:^(NSArray *cloudRecords, NSError *error) {
}];

// 查询 SD 卡录像
[EZOpenSDK searchRecordFileFromDevice:deviceSerial cameraNo:cameraNo beginTime:beginTime endTime:endTime completion:^(NSArray *deviceRecords, NSError *error) {
}];
```

### 回放完整流程
```objc
// 创建播放器
_player = [EZOpenSDK createPlayerWithDeviceSerial:deviceSerial cameraNo:cameraNo];
_player.delegate = self;
[_player setPlayVerifyCode:verifyCode];
[_player setPlayerView:self.playerView];

// 开始回放（云存储）
[_player startPlaybackFromCloud:cloudFile];
// 或开始回放（SD 卡）
[_player startPlaybackFromDevice:deviceFile];

// 停止回放
[_player stopPlayback];
```

### 回放消息回调

播放器消息码完整对照（预览和回放需要分别处理）：

| messageCode | 常量 | 含义 |
|-------------|------|------|
| 1 | PLAYER_REALPLAY_START | 直播开始 |
| 2 | PLAYER_VIDEOLEVEL_CHANGE | 直播流清晰度切换中 |
| 3 | PLAYER_STREAM_RECONNECT | 直播流取流正在重连 |
| 4 | PLAYER_VOICE_TALK_START | 对讲开始 |
| 5 | PLAYER_VOICE_TALK_END | 对讲结束 |
| 10 | PLAYER_STREAM_START | 录像取流开始 |
| 11 | PLAYER_PLAYBACK_START | 录像回放开始播放 |
| 12 | PLAYER_PLAYBACK_STOP | 录像回放结束播放 |
| 13 | PLAYER_PLAYBACK_FINISHED | 录像回放被用户强制中断 |
| 14 | PLAYER_PLAYBACK_PAUSE | 录像回放暂停 |
| 21 | PLAYER_NET_CHANGED | 播放器检测到wifi变换过 |
| 22 | PLAYER_NO_NETWORK | 播放器检测到无网络 |
| 25 | PLAYER_VIDEO_LEVEL_AUTO_IMPROVE | 网络好，建议升档 |
| 26 | PLAYER_VIDEO_LEVEL_AUTO_REDUCE | 网络差，建议降档 |

```objc
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        // 预览成功：隐藏加载UI、获取清晰度列表
        [self.loadingIndicator stopAnimating];
    } else if (messageCode == PLAYER_VOICE_TALK_START) {
        // 对讲成功建立：更新对讲UI状态
        // 非国标设备（devProtoEnum==0）需关闭player声音，避免回音
        if (self.deviceInfo.devProtoEnum == 0) {
            [_player closeSound];
        }
    } else if (messageCode == PLAYER_VOICE_TALK_END) {
        // 对讲结束：恢复player声音
        if (_isOpenSound) {
            [_player openSound];
        }
    } else if (messageCode == PLAYER_PLAYBACK_START) {
        // 回放成功：隐藏加载UI
        [self.loadingIndicator stopAnimating];
    } else if (messageCode == PLAYER_PLAYBACK_STOP) {
        // 回放结束/播放完成
    } else if (messageCode == PLAYER_NET_CHANGED) {
        // 网络变更：需要停止后重新取流
        [_player stopRealPlay];
        [_player startRealPlay];
    } else if (messageCode == PLAYER_VIDEO_LEVEL_AUTO_IMPROVE) {
        // 清晰度升档（需开启 enableDeviceAutoVideoLevel）
    } else if (messageCode == PLAYER_VIDEO_LEVEL_AUTO_REDUCE) {
        // 清晰度降档（需开启 enableDeviceAutoVideoLevel）
    }
}
```

**注意**：预览成功（messageCode=1）和回放成功（messageCode=11）是不同的消息码，不能混用。开始回放时只显示加载旋转指示器，不显示文本提示。

### 多片段连续播放

#### SD 卡录像
将第一个片段的 stopTime 设置为最后一个片段的 stopTime，调用 `startPlaybackFromDevice:` 即可连续播放。

#### 云存储录像
当所有片段的 `encryption`、`istorageVersion`、`cloudType` 一致时，同 SD 卡方式；否则需逐个播放，监听 `PLAYER_PLAYBACK_STOP` 后播放下一个。

#### 切换片段
使用 `seekPlayback:` 跳转，不断流：
```objc
[_player seekPlayback:selectedFile.startTime];
```

#### OSD 定时器
每秒获取 `getOSDTime`，匹配当前片段，更新选中高亮和进度条：
```objc
NSDate *osdTime = [_player getOSDTime];
```

## 五、回放控制

### 暂停/恢复
```objc
[_player pausePlayback];
[_player resumePlayback];
```

### 进度拖拽（Seek）
```objc
NSDate *seekTime = [recordFile.startTime dateByAddingTimeInterval:duringSeconds * slider.value];
[_player seekPlayback:seekTime];
```

### 倍数回放
```objc
[_player setPlaybackRate:rate mode:0];
```
支持的倍率（EZPlaybackRate 枚举）：
| 枚举值 | 说明 |
|--------|------|
| EZOPENSDK_PLAY_RATE_1_2 (=3) | 0.5 倍速（1/2 倍速） |
| EZOPENSDK_PLAY_RATE_1 (=1) | 1 倍速（正常） |
| EZOPENSDK_PLAY_RATE_2 (=2) | 2 倍速 |
| EZOPENSDK_PLAY_RATE_4 (=4) | 4 倍速 |
| EZOPENSDK_PLAY_RATE_8 (=6) | 8 倍速 |
| EZOPENSDK_PLAY_RATE_16 (=8) | 16 倍速 |
| EZOPENSDK_PLAY_RATE_32 (=10) | 32 倍速（云存储专用） |

倍速范围按录像类型区分：
- SD 卡录像：根据设备能力集动态获取支持的倍速列表，通过 `[EZBusinessTool getSDCardPlaybackRates:deviceInfo cameraInfo:cameraInfo streamFetchType:streamFetchType]` 获取设备实际支持的倍速选项（返回 nil 表示不支持倍速），不再写死固定范围
- 云录像：0.5x ~ 32x（固定选项）

UI 交互：点击工具栏倍速按钮弹出底部 BottomSheet 选择弹框（胶囊形选项按钮，选中纯黑填充+白字，未选中透明+描边），SD 卡录像仅展示设备能力集支持的倍速选项，选择后即时生效。

**SD 卡录像倍速选项动态渲染实现**（EZBusinessTool 中的方法，非 SDK 类，需自行实现）：

```objc
/**
 * 获取SD卡录像支持的倍数数组（根据设备能力集动态组装）
 * 规则：
 * 1. 先判断设备是否支持倍数回放(isSupportPlaybackRate)
 *    1.1 如果支持，通过 isSupportPlaybackSmallSpeed 和 getPlaybackMaxSpeed 获取支持倍数
 *        1.1.1 isSupportPlaybackSmallSpeed = YES：支持0.5、2倍；= NO：不支持0.5、2倍
 *        1.1.2 getPlaybackMaxSpeed = 1：不支持倍数；= 0（原始值-1被转化为0）：默认支持1、4、8、16倍；
 *              = 4：支持1、4倍；= 8：支持1、4、8倍；= 16：支持1、4、8、16倍
 *    1.2 如果不支持，判断是否内网直连(streamFetchType==2) && 支持内网直连倍数(isSupportDirectInnerRelaySpeed)
 *        - 是：返回 1、4、8 倍数
 *        - 否：返回 nil，表示不支持倍数回放
 */
+ (NSArray<NSNumber *> *)getSDCardPlaybackRates:(EZDeviceInfo *)deviceInfo cameraInfo:(EZCameraInfo *)cameraInfo streamFetchType:(int)streamFetchType {
    BOOL isSupportPlaybackRate = [self isSupportPlaybackRate:deviceInfo cameraInfo:cameraInfo];
    
    if (isSupportPlaybackRate) {
        NSMutableArray *rates = [NSMutableArray array];
        
        BOOL supportSmallSpeed = [self isSupportPlaybackSmallSpeed:deviceInfo cameraInfo:cameraInfo];
        if (supportSmallSpeed) {
            [rates addObject:@(EZOPENSDK_PLAY_RATE_1_2)];
        }
        
        int maxSpeed = [self getPlaybackMaxSpeed:deviceInfo cameraInfo:cameraInfo];
        if (maxSpeed == 1) {
            [rates addObject:@(EZOPENSDK_PLAY_RATE_1)];
        } else if (maxSpeed == 0) {
            [rates addObject:@(EZOPENSDK_PLAY_RATE_1)];
            if (supportSmallSpeed) {
                [rates addObject:@(EZOPENSDK_PLAY_RATE_2)];
            }
            [rates addObject:@(EZOPENSDK_PLAY_RATE_4)];
            [rates addObject:@(EZOPENSDK_PLAY_RATE_8)];
            [rates addObject:@(EZOPENSDK_PLAY_RATE_16)];
        } else {
            [rates addObject:@(EZOPENSDK_PLAY_RATE_1)];
            if (supportSmallSpeed) {
                [rates addObject:@(EZOPENSDK_PLAY_RATE_2)];
            }
            if (maxSpeed >= 4) {
                [rates addObject:@(EZOPENSDK_PLAY_RATE_4)];
            }
            if (maxSpeed >= 8) {
                [rates addObject:@(EZOPENSDK_PLAY_RATE_8)];
            }
            if (maxSpeed >= 16) {
                [rates addObject:@(EZOPENSDK_PLAY_RATE_16)];
            }
        }
        return [rates copy];
    } else {
        BOOL isSupportDirectInnerRelaySpeed = [self isSupportDirectInnerRelaySpeed:deviceInfo cameraInfo:cameraInfo];
        if (streamFetchType == 2 && isSupportDirectInnerRelaySpeed) {
            return @[@(EZOPENSDK_PLAY_RATE_1), @(EZOPENSDK_PLAY_RATE_4), @(EZOPENSDK_PLAY_RATE_8)];
        }
        return nil;
    }
}
```

## 六、录像下载

### 云存储录像下载
```objc
EZCloudRecordDownloadTask *task = [[EZCloudRecordDownloadTask alloc] initTaskWithID:taskId cloudRecordFile:cloudFile verifyCode:verifyCode savePath:path];
[task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {
    if (statusCode == EZRecordDownloaderStatusFinish) {
        // 下载完成，转存到相册
    }
} failed:^(NSError *error) {
}];
// 云存储下载进度回调（SD 卡录像不支持此回调）
// downloadSize 为已下载字节数，cloudFile.fileSize 为文件总大小
[task setDownloadCallBackWithDownloadSize:^(NSUInteger downloadSize) {
    NSUInteger percent = (cloudFile.fileSize > 0) ? (downloadSize * 100 / cloudFile.fileSize) : 0;
    // 主线程更新 UI（如录像封面右下角进度标签）
}];
[[EZRecordDownloader shareInstane] addDownloadTask:task];
```

**注意**：
- `setDownloadCallBackWithDownloadSize:` 仅云存储录像下载支持，SD 卡录像不支持
- `downloadSize` 是累计已下载字节数，配合 `cloudFile.fileSize` 可计算百分比
- 下载完成后（`EZRecordDownloaderStatusFinish`）需隐藏进度 UI
- 必须在回调中 `dispatch_async(dispatch_get_main_queue(), ...)` 切到主线程更新 UI
- `taskID` 必须传不同值（如录像在列表中的索引），SDK 内部通过 `taskID` 判断是否重复下载（相同 taskID 返回 -2）
- 双目设备需调用 `[task setMultiChannelDevice:YES]`

### ⚠️ 下载完成/失败后必须调用 stopDownloadTask 释放 taskID

SDK 内部通过 `taskID` 判断是否有重复下载任务。**下载完成或失败后必须调用 `stopDownloadTask:` 从 SDK 中移除 task**，否则相同 `taskID` 的下载请求会被拦截（`addDownloadTask:` 返回 -2），无法再次下载同一个片段。

```objc
// ✅ 正确 — 下载完成/失败后调用 stopDownloadTask 释放 taskID
__weak typeof(task) weakTask = task;
[task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {
    __strong typeof(weakTask) strongTask = weakTask;
    if (statusCode == EZRecordDownloaderStatusFinish) {
        // 下载成功处理...
    }
    // ⚠️ 必须调用！释放 SDK 内部对 taskID 的占用
    [[EZRecordDownloader shareInstane] stopDownloadTask:strongTask];
} failed:^(NSError *error) {
    __strong typeof(weakTask) strongTask = weakTask;
    // 下载失败处理...
    // ⚠️ 必须调用！释放 SDK 内部对 taskID 的占用
    [[EZRecordDownloader shareInstane] stopDownloadTask:strongTask];
}];

// ❌ 错误 — 不调用 stopDownloadTask，下次用相同 taskID 下载会被 SDK 拦截（返回 -2）
[task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {
    // 处理完成，但没有调用 stopDownloadTask
    // 下次下载同一个 segmentIndex 的录像时 addDownloadTask: 返回 -2，无回调
} failed:^(NSError *error) {}];
```

### addDownloadTask 返回值说明

```objc
int ret = [[EZRecordDownloader shareInstane] addDownloadTask:task];
// ret == 0  : 成功添加
// ret == -1 : 任务为空（task 无效）
// ret == -2 : 任务已在下载中（相同 taskID 已存在，需先 stopDownloadTask 释放）
```

### 页面退出时停止下载

页面退出（`viewWillDisappear`）时可以停止所有进行中的下载任务。已下载的 PS 文件（即使不完整）可直接转码保存：

```objc
// viewWillDisappear 中调用
NSArray *tasks = [[EZRecordDownloader shareInstane].tasks copy];
for (EZRecordDownloadTask *task in tasks) {
    NSString *psPath = task.saveFilePath;
    [[EZRecordDownloader shareInstane] stopDownloadTask:task];
    // 对已下载的 PS 文件直接转码保存到相册（即使未下载完，已下载部分也可解码为可播放的 MP4 片段）
    if (psPath && [[NSFileManager defaultManager] fileExistsAtPath:psPath]) {
        [EZVideoTransformer videoTransFormerPSPath:psPath toPath:mp4Path type:EZVideoTransformerTypeMP4 withKey:verifyCode streamId:0 streamStateVariable:NO succBlock:^{} processBlock:^(int rate){} failBlock:^(int errCode){}];
    }
}
```

### SD 卡录像下载
```objc
[[EZDeviceRecordDownloadTask alloc] initTaskWithID:taskId DeviceRecordFileInfo:deviceFile deviceSerial:serial cameraNo:cameraNo verifyCode:verifyCode savePath:path completion:^(EZDeviceRecordDownloadTask *task) {
    [task setDownloadCallBackWithFinshed:^(EZRecordDownloaderStatus statusCode) {} failed:^(NSError *error) {}];
    [[EZRecordDownloader shareInstane] addDownloadTask:task];
}];
```

### Swift 调用方式（ObjC→Swift 方法名映射）

以下标注 SDK API 在 Swift 中的正确调用方式，避免方法名/参数标签猜错：

```swift
// ======== 云录像下载 ========
let task = EZCloudRecordDownloadTask(
    taskWithID: UInt(taskId),        // ⚠️ 参数类型 UInt，不是 Int
    cloudRecordFile: cloudFile,
    verifyCode: verifyCode ?? "",
    savePath: savePath)

// 下载进度回调（仅云录像支持）
task.setDownloadCallBackWithDownloadSize { downloadSize in
    let percent = cloudFile.fileSize > 0 ? Int(downloadSize * 100 / UInt(cloudFile.fileSize)) : 0
}

// 完成/失败回调
task.setDownloadCallBackWithFinshed({ statusCode in
    // statusCode == .finish 表示成功
}, failed: { error in
    // 失败处理
})

// 添加到下载管理器
EZRecordDownloader.shareInstane().add(task)    // ⚠️ Swift 中是 add(_:) 不是 addDownloadTask

// ======== SD 卡录像下载（异步初始化） ========
let deviceTask = EZDeviceRecordDownloadTask()
deviceTask.initTask(
    withID: UInt(taskId),             // ⚠️ UInt 类型
    deviceRecordFileInfo: deviceFile,
    deviceSerial: serial,
    cameraNo: cameraNo,               // Int（NSInteger）
    verifyCode: verifyCode ?? "",
    savePath: savePath
) { task in                            // ⚠️ task 是非 Optional，不要用 let task = task 解包
    task.setDownloadCallBackWithFinshed({ statusCode in }, failed: { error in })
    EZRecordDownloader.shareInstane().add(task)
}

// ======== PS 文件转 MP4 ========
EZVideoTransformer.videoTransFormerPSPath(
    psPath,
    toPath: mp4Path,
    type: EZVideoTransformerTypeMP4,   // ⚠️ typedef enum 常量，Swift 中直接用全名
    withKey: verifyCode ?? "",
    streamId: 0,
    streamStateVariable: false,
    succBlock: { /* 转码成功 */ },
    processBlock: { rate in },
    fail: { errCode in }               // ⚠️ Swift 中是 fail: 不是 failBlock:
)

// ======== 停止下载 ========
// let tasks = EZRecordDownloader.shareInstane().tasks ?? []  // 已废弃
EZRecordDownloader.shareInstane().stop(task)    // 停止单个任务
```

**⚠️ Swift 调用时的常见坑**：
- `taskWithID` 参数是 `UInt`（ObjC 的 `NSUInteger`），不是 `Int`
- `addDownloadTask` 在 Swift 中被重命名为 `add(_:)`
- `EZDeviceRecordDownloadTask` 用 `init()` 创建空对象后调用 `initTask(withID:...)` 方法，completion 中的 task 参数是非 Optional
- `EZVideoTransformerTypeMP4` 是 `typedef enum` 常量（不是 `NS_ENUM`），Swift 中直接用全名
- `failBlock:` 参数在 Swift 中是 `fail:`

### 云录像封面

云录像文件 `EZCloudRecordFile` 自带 `coverPic` 属性（截图 URL），通过 POST 请求下载：

```objc
EZCloudRecordFile *cloudFile = ...;
NSURL *url = [NSURL URLWithString:cloudFile.coverPic];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"POST";
// 加密设备需附带验证码用于解密
NSString *body = [NSString stringWithFormat:@"%@&x=%d&decodekey=%@", cloudFile.coverPic, 200, verifyCode];
request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];

NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
    UIImage *image = [UIImage imageWithData:data];
    // 更新对应 cell
}];
[task resume];
```

### SD 卡录像封面

**重要**：传给 `requestRecordCover:` 的录像列表顺序必须与 UI 展示顺序一致。SDK 按传入顺序逐个提取封面并通过 `seq` 索引回调，这样用户看到的封面加载是从列表第一个开始。

```objc
// 初始化（viewDidLoad）
[[EZRecordCoverFetcherManager sharedInstance] initFetcherWithDeviceSerial:serial cameraNo:cameraNo];
[EZRecordCoverFetcherManager sharedInstance].fetcherDelegate = self;

// 请求封面（按 UI 展示顺序传入，seq 从 0 开始递增）
for (int i = 0; i < records.count; i++) {
    records[i].seq = i;
}
[[EZRecordCoverFetcherManager sharedInstance] requestRecordCover:records];

// 代理回调（seq 对应传入列表的索引，通过遍历匹配对象而非直接索引）
- (void)onGetCoverSuccess:(int)seq data:(NSData *)data {
    // 通过 seq 找到对应的录像对象
    EZDeviceRecordFile *targetFile = nil;
    for (EZDeviceRecordFile *file in allRecords) {
        if (file.seq == seq) { targetFile = file; break; }
    }
    targetFile.imageData = data;
    // 局部刷新对应 cell（reloadItemsAtIndexPaths）
}

// 释放（dealloc）
[[EZRecordCoverFetcherManager sharedInstance] stopFetcher];
```

## 七、截图

### 播放器截图（取流过程中）
```objc
// 耗时操作，需在子线程
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    UIImage *image = [_player capturePicture:100]; // quality 0~100
});
```

### 水印截图
```objc
UIImage *image = [_player captureRenderPictureWithWidth:width height:height];
```
仅支持单目设备。

## 八、录制

### 普通录制
```objc
// 开始录制（路径为沙盒路径）
NSString *path = [NSString stringWithFormat:@"%@/%@.mp4", recordDir, timestamp];
[_player startLocalRecordWithPathExt:path];

// 停止录制
[_player stopLocalRecordExt:^(BOOL ret) {
    // 转存到相册
    [self saveRecordToPhotosAlbum:path];
}];
```

### 水印录制
```objc
[_player startRenderRecordWithPath:path];
[_player stopRenderRecord:^(BOOL ret) {}];
```
仅支持单目设备。页面退出时需在 viewWillDisappear 中停止录制。

## 九、声音控制

```objc
[_player openSound];   // 开启声音
[_player closeSound];  // 关闭声音
```
取流成功后声音默认开启。

## 十、清晰度

### 获取设备支持的清晰度
```objc
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        [_player getDeviceDetailInfo:^(EZDeviceDetailPublicInfo *info) {
            NSArray *qualityInfos = info.videoQualityInfos;
        }];
    }
}
```

### 设置清晰度
```objc
[EZOpenSDK setVideoLevel:deviceSerial cameraNo:cameraNo videoLevel:videoLevel completion:^(NSError *error) {
    if (!error) {
        [_player stopRealPlay];
        [_player startRealPlay];
    }
}];
```

### 清晰度自适应（自动清晰度）

当设备支持自动清晰度能力时，SDK 会监测网络状况并通过 `didReceivedMessage:` 回调通知升档/降档。

#### 1. 判断设备是否支持
```objc
BOOL support = [EZBusinessTool isSupportDeviceAutoVideolevel:deviceInfo cameraInfo:cameraInfo];
```

#### 2. 开启网络检测并重新取流
```objc
[self.previewPlayer stopRealPlay];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    [self.previewPlayer enableDeviceAutoVideoLevel];
    [self.previewPlayer startRealPlay];
});
```

**⚠️ 关键时序约束**：`enableDeviceAutoVideoLevel` **必须在 `startRealPlay` 之前调用**，取流成功后调用无效。因此：
- 首次进入预览页面时，应在 `startRealPlay` 之前从 NSUserDefaults 读取自动清晰度缓存状态，如果为 YES 则先调用 `enableDeviceAutoVideoLevel`
- 用户手动切换到自动模式时，需要 `stopRealPlay` → `enableDeviceAutoVideoLevel` → `startRealPlay`
- **禁止** 在取流成功回调（`PLAYER_REALPLAY_START`）中调用 `enableDeviceAutoVideoLevel`（无效且可能导致黑屏闪烁）

```objc
// ✅ 正确 — 首次取流前判断并开启
NSString *autoKey = [NSString stringWithFormat:@"ez_quality_auto_%@", deviceSerial];
BOOL savedAutoState = [[NSUserDefaults standardUserDefaults] boolForKey:autoKey];
if (savedAutoState) {
    [self.player enableDeviceAutoVideoLevel]; // 必须在 startRealPlay 之前
}
[self.player startRealPlay];

// ❌ 错误 — 取流成功后调用（无效）
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == PLAYER_REALPLAY_START) {
        [self.player enableDeviceAutoVideoLevel]; // 此时调用无效！
    }
}
```

#### 3. 监听升降档消息并切换清晰度
```objc
- (void)player:(EZPlayer *)player didReceivedMessage:(NSInteger)messageCode {
    if (messageCode == 25) { // PLAYER_VIDEO_LEVEL_AUTO_IMPROVE - 网络好，建议升档
        if (self.isAutoVideoLevel && !self.isRecording) {
            NSInteger higher = [EZBusinessTool getHigherVideoLevel:self.currentVideoLevel
                                           deviceDetailPublicInfo:self.deviceDetailPublicInfo];
            if (higher > self.currentVideoLevel) {
                [self setVideoLevelAuto:higher];
            }
        }
    } else if (messageCode == 26) { // PLAYER_VIDEO_LEVEL_AUTO_REDUCE - 网络差，建议降档
        if (self.isAutoVideoLevel && !self.isRecording) {
            NSInteger lower = [EZBusinessTool getLowerVideoLevel:self.currentVideoLevel
                                         deviceDetailPublicInfo:self.deviceDetailPublicInfo];
            if (lower < self.currentVideoLevel) {
                [self setVideoLevelAuto:lower];
            }
        }
    }
}
```

#### 4. 执行自动清晰度切换（非强制，不断流）
```objc
[EZOpenSDK setVideoLevelAuto:deviceSerial
                    cameraNo:cameraNo
                  videoLevel:(EZVideoLevelType)targetLevel
                  completion:^(NSError *error) {
    if (!error) {
        self.currentVideoLevel = targetLevel;
        [self updateQualityButtonTitle]; // 显示"AUTO(4K)"格式
    }
}];
```

#### 注意事项
- 录制过程中不能调用（分辨率变化影响转码）
- `enableDeviceAutoVideoLevel` 未调用时不会收到升降档消息
- UI 按钮显示格式："AUTO(清晰度名称)"，如"AUTO(高清)"、"AUTO(4K)"
- 自动清晰度状态需本地持久化（`NSUserDefaults`，key 格式 `ez_quality_auto_{deviceSerial}`），下次进入页面自动恢复
- **清晰度选择面板**：支持自动清晰度的设备，必须在清晰度选项列表顶部添加"AUTO"选项，选中时显示"✓ AUTO(当前清晰度名称)"；用户手动选择具体清晰度时应关闭自动模式
- **⚠️ 防循环触发**：恢复自动清晰度状态时必须先判断 `isAutoVideoLevel` 是否已为 YES，避免 `PLAYER_REALPLAY_START → fetchVideoQualityInfos → setAutoVideoLevel:YES → stopRealPlay/startRealPlay → PLAYER_REALPLAY_START` 的无限循环导致画面闪烁

## 十一、云台控制

### 云台操作（推荐基线客户使用）

> **注意**：云台相关枚举定义在 `EZConstants.h` 中，必须导入 `#import <EZOpenSDKFramework/EZConstants.h>`。

```objc
// 开始转动（EZPTZActionStart）
[EZOpenSDK controlPTZMix:deviceSerial cameraNo:cameraNo command:EZPTZCommandLeft action:EZPTZActionStart newSpeed:4 result:^(NSError *error) {
}];

// 停止转动（EZPTZActionStop）
[EZOpenSDK controlPTZMix:deviceSerial cameraNo:cameraNo command:EZPTZCommandLeft action:EZPTZActionStop newSpeed:4 result:^(NSError *error) {
}];
```

EZPTZCommand 枚举值（NS_OPTIONS 位移枚举，定义在 EZConstants.h，**必须直接使用枚举常量名，禁止写数字**）：
- `EZPTZCommandLeft` — 向左旋转
- `EZPTZCommandRight` — 向右旋转
- `EZPTZCommandUp` — 向上旋转
- `EZPTZCommandDown` — 向下旋转
- `EZPTZCommandZoomIn` — 镜头拉近
- `EZPTZCommandZoomOut` — 镜头拉远

EZPTZAction 枚举值（NS_ENUM，定义在 EZConstants.h，**必须直接使用枚举常量名，禁止写数字**）：
- `EZPTZActionStart` — 开始转动
- `EZPTZActionStop` — 停止转动

**⚠️ 禁止** 用数字（0/1/2/3/4 等）代替枚举值。这些枚举的实际数值不是连续的，直接写数字一定会错。必须使用 SDK 定义的枚举常量名。

### 云台角度回调
```objc
- (void)player:(EZPlayer *)player didReceivedDevicePtzAngleInfo:(EZDevicePtzAngleInfo *)info {
    // info.horizontalCurrentAngle / info.verticalCurrentAngle
}
```

## 十二、智能分析
```objc
[_player setIntelAnalysis:YES]; // 预览成功后调用
```

## 十三、水印

```objc
EZPlayWaterMarkConfig *config = [[EZPlayWaterMarkConfig alloc] init];
config.fontArray = @[@"水印信息：", @"杭州萤石网络有限公司"];
config.fontAlignment = EZWaterMarkTextAlignmentCenter;
config.fontRotateAngle = 45.f;
config.fontColorAdapt = YES;
config.windowAdaptMode = EZWaterMarkWindowAdaptModeFontRowColumn;

// 取流成功后设置
[_player setWaterMarkFont:config];

// 清除水印
[_player clearWaterMarkFont];
```
仅支持单目设备。

## 十四、门锁设备开门 token

```objc
- (void)player:(EZPlayer *)player playPrivateTokenInfo:(EZPMPlayPrivateTokenInfo *)tokenInfo {
    NSString *token = tokenInfo.token;
    // 使用 token + 平台接口实现远程开门
}
```

## 十五、告警消息

### 告警列表页 UI 必须包含的组件

实现告警列表页面时，**每条告警 Cell 必须展示告警图片缩略图**，布局为左侧图片 + 右侧文字信息：

| 组件 | 说明 | 数据来源 |
|------|------|----------|
| **告警图片** | 左侧缩略图（约 120×68pt，圆角裁切） | `EZAlarmInfo.alarmPicUrl`，需下载+解密 |
| **告警名称** | 告警类型名称 | `EZAlarmInfo.alarmName` |
| **告警时间** | 告警发生时间 | `EZAlarmInfo.alarmStartTime` |
| **未读标记** | 蓝色圆点标识未读 | `EZAlarmInfo.isRead` |

### 获取告警消息列表
```objc
[EZOpenSDK getAlarmList:deviceSerial pageIndex:0 pageSize:20 beginTime:beginTime endTime:endTime completion:^(NSArray *alarmList, NSInteger totalCount, NSError *error) {
}];
```

### ⚠️ 告警图片加载（完整流程）

告警图片加载是告警列表页面的核心功能，**必须在 Cell 中展示告警缩略图**。流程分三步：下载 → 判断加密 → 解密/显示。

#### 步骤一：从 alarmPicUrl 下载原始图片数据

```objc
NSURL *url = [NSURL URLWithString:alarm.alarmPicUrl];
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
    if (!error && data.length > 0) {
        // 步骤二：根据 crypt 判断是否需要解密
        NSData *imageData = [self decryptAlarmImageData:data alarm:alarm];
        UIImage *image = [UIImage imageWithData:imageData];
        // 主线程更新 UI
    }
}];
[task resume];
```

#### 步骤二：根据 crypt 字段判断加密方式并解密

`EZAlarmInfo.crypt` 决定了图片的加密方式，**必须按以下分支处理**：

| crypt 值 | 含义 | 解密密钥 | API 调用 |
|-----------|------|----------|----------|
| 0 | 不确定 | 需解析 URL 中 `isEncrypted` 参数判断 | 见下方代码 |
| 1 | 设备加密 | 设备验证码（从 PreviewModule 缓存读取） | `decryptData:verifyCode:encryptType:1` |
| 2 | 平台加密 | `EZAlarmInfo.checksum` | `decryptData:verifyCode:encryptType:2` |

```objc
- (NSData *)decryptAlarmImageData:(NSData *)data alarm:(EZAlarmInfo *)alarm {
    if (alarm.crypt == 2) {
        // 平台加密：使用 checksum 作为解密密钥
        if (alarm.checksum.length > 0) {
            NSData *decrypted = [EZOpenSDK decryptData:data verifyCode:alarm.checksum encryptType:2];
            return decrypted ?: data;
        }
    } else if (alarm.crypt == 1) {
        // 设备加密：使用设备验证码解密
        NSString *verifyCode = [PreviewModule cachedVerifyCodeForDevice:alarm.deviceSerial];
        if (verifyCode.length > 0) {
            NSData *decrypted = [EZOpenSDK decryptData:data verifyCode:verifyCode encryptType:1];
            return decrypted ?: data;
        }
    } else if (alarm.crypt == 0) {
        // 不确定加密状态：通过 URL 中 isEncrypted 参数判断
        if ([alarm.alarmPicUrl containsString:@"isEncrypted=1"]) {
            NSString *verifyCode = [PreviewModule cachedVerifyCodeForDevice:alarm.deviceSerial];
            if (verifyCode.length > 0) {
                NSData *decrypted = [EZOpenSDK decryptData:data verifyCode:verifyCode];
                return decrypted ?: data;
            }
        }
        // isEncrypted=0 或无此参数，不需要解密
    }
    return data;
}
```

#### 步骤三：图片缓存与 Cell 复用

- **必须**使用内存缓存（NSMutableDictionary 以 alarmPicUrl 为 key）避免重复下载
- **必须**使用 loadingURLs 集合防止同一图片并发请求
- **必须**注意 Cell 复用导致的图片错位问题（下载完成后 reloadData 或通过 URL 匹配验证）
- 下拉刷新时清空图片缓存

#### 解密 API 签名

```objc
// 方式一：默认设备加密
+ (NSData *)decryptData:(NSData *)data verifyCode:(NSString *)verifyCode;

// 方式二：指定加密类型（推荐）
// type: 1-设备加密（verifyCode 为设备验证码），2-平台加密（verifyCode 为 checksum）
+ (NSData *)decryptData:(NSData *)data verifyCode:(NSString *)verifyCode encryptType:(NSInteger)type;
```

#### 注意事项

- `alarmPicUrl` 是 NSString 类型，下载前需转为 NSURL
- 设备加密时的验证码通过 `[PreviewModule cachedVerifyCodeForDevice:]` 获取本地缓存
- 如果缓存中无验证码（用户从未在预览页输入过），设备加密图片将无法解密，显示原始加密数据（可显示占位图）
- 图片下载和解密应在子线程执行，UI 更新切回主线程
- 建议为 imageView 设置灰色占位背景色，图片加载成功前用户有视觉反馈

### 事件回放

告警事件发生时，设备可能同时录制了告警录像。通过 `preTime` 和 `delayTime` 计算录像时间范围：

```objc
// 录像开始时间 = 告警开始时间 - preTime
NSDate *beginTime = [alarmInfo.alarmStartTime dateByAddingTimeInterval:-alarmInfo.preTime];
// 录像结束时间 = 告警开始时间 + delayTime
NSDate *endTime = [alarmInfo.alarmStartTime dateByAddingTimeInterval:alarmInfo.delayTime];

// 先查 SD 卡录像，无结果再查云存储
[EZOpenSDK searchRecordFileFromDevice:alarmInfo.deviceSerial cameraNo:alarmInfo.cameraNo beginTime:beginTime endTime:endTime completion:^(NSArray *deviceRecords, NSError *error) {
    if (deviceRecords.count > 0) {
        // 播放 SD 卡录像
    } else {
        // 查询云存储录像
        [EZOpenSDK searchRecordFileFromCloud:alarmInfo.deviceSerial cameraNo:alarmInfo.cameraNo beginTime:beginTime endTime:endTime completion:^(NSArray *cloudRecords, NSError *error) {
            // 播放云存储录像
        }];
    }
}];
```

### 告警消息开关（布防/撤防）

```objc
/**
 * 设备布防状态设置
 * IPC 设备：0-撤防，1-布防
 * A1 等设备：0-睡眠，8-在家，16-外出
 */
[EZOpenSDK setDefence:EZDefenceStatus_IPC_OPEN
         deviceSerial:deviceSerial
           completion:^(NSError *error) {
    if (!error) {
        NSLog(@"布防成功");
    }
}];
```

### 未读消息数

```objc
/**
 * 获取未读告警消息数
 * deviceSerial: 设备序列号，为 nil 时返回账户下所有设备的未读消息数
 * type: EZMessageTypeAlarm(1) 告警消息，EZMessageTypeLeave(2) 留言消息
 */
[EZOpenSDK getUnreadMessageCount:deviceSerial
                     messageType:EZMessageTypeAlarm
                      completion:^(NSInteger count, NSError *error) {
    if (!error) {
        NSLog(@"未读告警消息数: %ld", (long)count);
    }
}];
```

### 设置告警已读

```objc
/**
 * 标记告警消息为已读
 * alarmIds: 告警 ID 数组，最多 10 个
 * alarmStatus: EZMessageStatusRead
 */
[EZOpenSDK setAlarmStatus:@[alarm.alarmId]
              alarmStatus:EZMessageStatusRead
               completion:^(NSError *error) {
    if (!error) {
        NSLog(@"标记已读成功");
    }
}];
```

### 删除告警

```objc
/**
 * 删除告警消息
 * alarmIds: 告警 ID 数组，最多 10 个
 */
[EZOpenSDK deleteAlarm:@[alarm.alarmId] completion:^(NSError *error) {
    if (!error) {
        NSLog(@"删除告警成功");
    }
}];
```

## 十六、其他播放器功能

### 硬解开关
```objc
[_player setHardDecode:YES]; // startRealPlay 之前调用
```

### 画面旋转
```objc
[_player setPlayerViewRotation:EZPlayerViewRotationAngle90];
```

### 电子放大
通过 `setDisplayRegionEx:(CGRect *)rect streamId:(int)streamId` 设置显示区域实现电子放大：
```objc
// 设置显示区域（归一化坐标 0~1，全画面为 {0,0,1,1}）
// **重要**：rect 参数必须传实例变量（ivar）的地址，不能传局部变量地址（SDK 内部持续引用该指针）
@property (nonatomic, assign) CGRect zoomRectValue; // 初始 {0,0,1,1}

[_player setDisplayRegionEx:&_zoomRectValue streamId:0];

// 恢复全画面
_zoomRectValue = CGRectMake(0, 0, 1, 1);
[_player setDisplayRegionEx:&_zoomRectValue streamId:0];

// 双目设备第二路：streamId 传 1
[_player setDisplayRegionEx:&_zoomRectValue streamId:1];
```


## 十七、隐私遮蔽

### 能力集判断

```objc
// 判断设备是否支持隐私遮蔽（能力集位 388）
BOOL supportPrivacy = ([deviceInfo getSupportInt:388] == 1);
```

### 查询隐私遮蔽状态

通过萤石开放平台 HTTP 接口查询（非 SDK 方法）：

```objc
NSString *urlStr = @"https://open.ys7.com/api/v3/device/video/switch/status?type=7";
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr]];
request.HTTPMethod = @"GET";
[request setValue:accessToken forHTTPHeaderField:@"accessToken"];
[request setValue:deviceSerial forHTTPHeaderField:@"deviceSerial"];
// 返回 JSON 中 data.enable == 1 表示隐私遮蔽已开启
```

### 切换隐私遮蔽

```objc
NSString *urlStr = @"https://open.ys7.com/api/v3/device/video/switch/status";
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr]];
request.HTTPMethod = @"PUT";
[request setValue:accessToken forHTTPHeaderField:@"accessToken"];
// Body: type=7&deviceSerial=xxx&enable=1（开启）或 enable=0（关闭）
```

### 获取 AccessToken

```objc
NSString *token = [EZOpenSDK getAccesstoken]; // 注意：方法名小写 t
```

### 注意事项

- 隐私遮蔽开启时不能预览，需停止播放并显示覆盖层
- 隐私遮蔽开启时点击播放按钮应 Toast 提示，不执行播放
- 关闭隐私遮蔽后需恢复预览
- 设备列表页面可批量查询在线设备的隐私状态（仅查询 `getSupportInt:388 == 1` 的在线设备）
- iOS 获取 token 方法名是 `getAccesstoken`（小写 t），不是 `getAccessToken`


## 二十、紧凑模式（高比例设备适配）

### 触发条件

播放窗口宽高比 <= 1 时进入紧凑模式（如 9:16 设备宽高比=0.5625，双目设备 16:18 宽高比=0.89）。双目设备也属于紧凑模式。

```objc
CGFloat playerRatio = [UIScreen mainScreen].bounds.size.width / self.playerHeightConstraint.constant;
self.isCompactMode = (self.isMultiChannelDevice || playerRatio <= 1.0);
```

注意：`isCompactMode` 必须在 `setupUI` 之后、`setupPreviewControls` 之前计算并赋值（因为 setupPreviewControls 中需要根据紧凑模式决定功能栏按钮数量）。

### 紧凑模式布局调整

1. **播放器窗口缩放**：高度限制为屏幕 60%，宽度等比例缩小，居中显示（centerX 约束 + playerWidthConstraint），播放器区域背景黑色
2. **导航栏**：隐藏系统导航栏，在播放窗口左上角添加浮动返回按钮（tag=6666，半透明黑底圆形）+ 设备名称标签（tag=6665，带半透明背景胶囊 tag=6664）
3. **设备名称联动**：设备名称和背景胶囊随工具栏联动显隐（alpha 动画），返回按钮始终可见
4. **功能栏**：紧凑模式下如果设备支持云台，功能栏中显示云台按钮（支持隐私遮蔽时5个按钮，不支持时云台替换隐私位置4个按钮），用 UIScrollView + UIStackView 实现水平滑动
5. **云台面板**：覆盖模式（从功能栏到页面底部），右上角有关闭按钮，带滑入/滑出动画
6. **对讲面板**：覆盖模式（从播放器底部到页面底部），挂断后不恢复云台面板

### 竖屏全屏

紧凑模式下（非双目设备）点击全屏按钮，不旋转屏幕，而是竖屏全屏：

- 禁用 AutoLayout 约束（translatesAutoresizingMaskIntoConstraints = YES），直接设置 playerView.frame
- 播放窗口宽度撑满屏幕，高度按设备原始宽高比等比例缩放（`screenWidth * 16.0 / 9.0`）
- 播放窗口垂直居中（y = (screenHeight - fullHeight) / 2）
- 页面背景设为黑色，上下多余空间自然黑屏
- 工具栏显示在播放窗口底部（横屏撑满，约束到 self.view）
- 其他 UI 全部隐藏（浮动导航栏、功能栏、云台、tab）
- 左上角显示返回按钮（tag=8888）
- 全屏图标切换为缩小图标

退出竖屏全屏时：
- 恢复 AutoLayout（translatesAutoresizingMaskIntoConstraints = NO）
- 重新激活 playerTopConstraint、playerWidthConstraint、playerHeightConstraint
- 恢复背景色、显示其他 UI

### 横屏全屏（非紧凑模式设备）

16:9 等非紧凑模式设备点击全屏按钮，旋转到横屏全屏：

- 禁用 playerTopConstraint、playerHeightConstraint、**playerWidthConstraint**
- 添加 top/bottom/leading/trailing 约束撑满整个 view
- 强制横屏旋转
- 退出时移除 leading/trailing 约束，恢复 playerTopConstraint、playerHeightConstraint、playerWidthConstraint

### 工具栏约束

工具栏的 leading/trailing 约束到 `self.view`（而非 playerView），确保横屏撑满，不跟随播放窗口缩放。

### 设备名称显示规则（与 Android 一致）

- `deviceName` 不为空 → 直接显示
- 为空 → `category(序列号)` 格式

```objc
NSString *displayName = device.deviceName;
if (displayName.length == 0) {
    NSString *category = device.category ?: @"";
    displayName = [NSString stringWithFormat:@"%@(%@)", category, device.deviceSerial ?: @""];
}
```
