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

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

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

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

- **禁止** 定义 `private static final int MSG_REALPLAY_PLAY_SUCCESS = 1` 等自定义数字常量
- **必须** 直接使用 SDK 常量：`EZRealPlayConstants.MSG_REALPLAY_PLAY_SUCCESS`、`EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL`、`EZRealPlayConstants.MSG_REALPLAY_VOICETALK_SUCCESS` 等
- **必须** import：`import com.videogo.openapi.EZConstants.EZRealPlayConstants;`（预览）或 `import com.videogo.openapi.EZConstants.EZPlaybackConstants;`（回放）

```java
// ✅ 正确
case EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL:

// ❌ 错误 — 禁止自定义常量值
private static final int MSG_REALPLAY_PLAY_FAIL = 2;
case MSG_REALPLAY_PLAY_FAIL:
```

### 2. 错误信息必须从 msg.obj 获取

- **禁止** 从 `msg.arg1` 或 `msg.arg2` 获取错误码
- **必须** 从 `msg.obj` 强转为 `ErrorInfo` 对象，通过 `errorInfo.errorCode` 获取错误码
- **必须** import：`import com.videogo.errorlayer.ErrorInfo;`

```java
// ✅ 正确
case EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL:
    ErrorInfo errorInfo = (ErrorInfo) msg.obj;
    int errorCode = errorInfo.errorCode;

// ❌ 错误 — msg.arg1 不是错误码
case EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL:
    int errorCode = msg.arg1;
```

### 3. 错误码比较必须使用 SDK 常量

- **禁止** 硬编码数字：`errorCode == 400035`
- **必须** 使用 `ErrorCode` 类中的常量：`ErrorCode.ERROR_INNER_VERIFYCODE_NEED`、`ErrorCode.ERROR_INNER_VERIFYCODE_ERROR`
- **必须** import：`import com.videogo.exception.ErrorCode;`

```java
// ✅ 正确
if (errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_NEED
    || errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_ERROR) {

// ❌ 错误 — 禁止硬编码错误码数字
if (errorCode == 400035 || errorCode == 400036) {
```

### 4. 播放器消息回调的标准模式

无论使用何种架构（MVC/MVP/MVVM），Handler 中的 `handleMessage` 必须遵循以下模式：

```java
@Override
public boolean handleMessage(Message msg) {
    switch (msg.what) {
        case EZRealPlayConstants.MSG_REALPLAY_PLAY_SUCCESS:
            // 播放成功处理
            break;
        case EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL:
            ErrorInfo errorInfo = (ErrorInfo) msg.obj;
            if (errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_NEED
                || errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_ERROR) {
                // 弹出验证码输入框，用户输入后调用 player.setPlayVerifyCode(code) 再 startRealPlay
            }
            break;
        case EZRealPlayConstants.MSG_REALPLAY_VOICETALK_SUCCESS:
            // 对讲成功
            break;
        case EZRealPlayConstants.MSG_REALPLAY_VOICETALK_FAIL:
            ErrorInfo talkError = (ErrorInfo) msg.obj;
            // 对讲失败处理
            break;
    }
    return false;
}
```

### 5. 回放消息同理

```java
case EZPlaybackConstants.MSG_REMOTEPLAYBACK_PLAY_SUCCUSS:
    // 回放成功
    break;
case EZPlaybackConstants.MSG_REMOTEPLAYBACK_PLAY_FAIL:
    ErrorInfo errorInfo = (ErrorInfo) msg.obj;
    // 回放失败处理（验证码逻辑同预览）
    break;
case EZPlaybackConstants.MSG_REMOTEPLAYBACK_PLAY_FINISH:
    // 回放完成
    break;
```

### 6. 清晰度列表必须从播放器获取

- **禁止** 从 `EZCameraInfo.getVideoQualityInfos()` 获取清晰度列表（该接口返回的是设备注册时的静态数据，可能不准确，会出现重复清晰度）
- **必须** 在取流成功后从 `player.getDeviceDetailInfo().videoQualityInfos` 获取
- `EZDeviceDetailPublicInfo` 的属性是 public 字段，**直接访问，不要用 getter**

```java
// ✅ 正确 — 取流成功后从播放器获取
EZDeviceDetailPublicInfo info = mEZPlayer.getDeviceDetailInfo();
ArrayList<EZVideoQualityInfo> qualityInfos = info.videoQualityInfos; // 直接访问字段
int currentLevel = info.videoLevel; // int 类型，直接访问

// ❌ 错误 — 从 EZCameraInfo 获取（静态数据，可能不准确）
List<EZCameraInfo> cameras = deviceInfo.getCameraInfoList();
List<EZVideoQualityInfo> qualityInfos = cameras.get(0).getVideoQualityInfos();

// ❌ 错误 — 用 getter 访问（不存在这些方法）
info.getVideoQualityInfos();
info.getVideoLevel();
```

### 7. 清晰度选择弹窗必须包含"自动"选项

- 如果设备支持自动清晰度（`deviceInfo.isSupportDeviceAutoVideolevel()` 返回 true），清晰度选择列表**末尾必须追加"自动"选项**
- 用户选择"自动"后，调用 `player.enableDeviceAutoVideoLevel()` 开启网络检测，然后重新取流
- 自动模式下，清晰度按钮显示"自动(当前清晰度)"

### 8. 录制必须使用 StreamDownloadCallback

- **禁止** 在 `stopLocalRecord()` 后立即读取/复制录制文件（文件可能未写完）
- **必须** 在开始录制前调用 `player.setStreamDownloadCallback()`，在 `onSuccess(filepath)` 回调中保存文件到相册
- **禁止** 在 `onSuccess` 回调中对文件做 `file.exists()` 或 `file.length() > 0` 判断（SDK 回调时文件系统元数据可能未同步，但文件内容已就绪）
- 录制路径**必须为沙盒路径**（`context.getExternalCacheDir()`），不能直接指定相册路径

```java
// ✅ 正确 — 先设置回调，再开始录制，在 onSuccess 中保存到相册
player.setStreamDownloadCallback(new EZOpenSDKListener.EZStreamDownloadCallback() {
    @Override
    public void onSuccess(String filepath) {
        // 直接保存，不做 exists() 判断
        File recordFile = new File(filepath);
        saveVideoToGallery(context, recordFile, durationSec);
    }
    @Override
    public void onError(EZOpenSDKListener.EZStreamDownloadError code) { }
});
player.startLocalRecordWithFile(sandboxPath);

// ❌ 错误 — stopLocalRecord 后立即复制文件（文件可能不完整）
player.stopLocalRecord();
File file = findLatestFile(recordDir); // 文件可能还没写完
copyToGallery(file); // 视频损坏，无法播放
```

### 9. 保存视频到相册必须包含完整元数据

- **必须** 设置以下 MediaStore 字段：`TITLE`、`DISPLAY_NAME`、`MIME_TYPE`、`DATE_TAKEN`、`DATE_MODIFIED`、`DATE_ADDED`、`DURATION`（毫秒）、`SIZE`
- **禁止** 只设置 `DISPLAY_NAME` 和 `MIME_TYPE`（缺少 DURATION 会导致相册无法播放）
- Android 10+ 使用 `RELATIVE_PATH` + `OutputStream` 复制文件内容
- Android 9 及以下使用 `DATA` 字段 + `ACTION_MEDIA_SCANNER_SCAN_FILE` 广播

```java
// ✅ 正确 — 完整的 MediaStore 元数据
ContentValues values = new ContentValues();
values.put(MediaStore.Video.Media.TITLE, file.getName());
values.put(MediaStore.Video.Media.DISPLAY_NAME, file.getName());
values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4");
values.put(MediaStore.Video.Media.DATE_TAKEN, System.currentTimeMillis());
values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
values.put(MediaStore.Video.Media.DATE_ADDED, System.currentTimeMillis() / 1000);
values.put(MediaStore.Video.Media.DURATION, durationSec * 1000); // 毫秒
values.put(MediaStore.Video.Media.SIZE, file.length());

// ❌ 错误 — 缺少关键元数据，相册无法正确播放
ContentValues values = new ContentValues();
values.put(MediaStore.Video.Media.DISPLAY_NAME, fileName);
values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4");
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES);
```

### 10. 预览和回放页面必须竖屏

- 预览页面（PreviewActivity）和回放页面（PlaybackActivity）的 `AndroidManifest.xml` 中 `screenOrientation` **必须设置为 `portrait`**
- **禁止** 将回放页面设置为 `landscape`（横屏），除非用户明确要求横屏模式

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

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

```java
// ✅ 正确 — 回放页面从本地缓存读取验证码
SharedPreferences prefs = getSharedPreferences("verify_code_prefs", Context.MODE_PRIVATE);
String cachedCode = prefs.getString("verify_code_" + deviceSerial, null);
if (cachedCode != null && !cachedCode.isEmpty()) {
    mVerifyCode = cachedCode;
}

// ❌ 错误 — 仅依赖 Intent 传递验证码（预览页面输入的验证码可能未通过 Intent 传递）
mVerifyCode = getIntent().getStringExtra("verify_code");
```

### 12. 云录像封面必须拼接参数并使用图片加载库加载

- **禁止** 直接使用 `file.getCoverPic()` 原始 URL 作为封面地址（缺少缩略图参数，加密设备无法解密）
- **必须** 拼接 `&x=200` 缩略图参数
- **必须** 判断 `file.getEncryption()` 是否非空，非空时拼接 `&decodekey=验证码`
- **禁止** 在 Adapter 中写 `// TODO` 占位，**必须** 使用 Glide 等图片加载库实际加载封面

```java
// ✅ 正确 — 拼接完整封面 URL
String rawCoverUrl = file.getCoverPic();
if (rawCoverUrl != null && !rawCoverUrl.isEmpty()) {
    StringBuilder sb = new StringBuilder(rawCoverUrl);
    sb.append("&x=200");
    String encryption = file.getEncryption();
    if (encryption != null && !encryption.isEmpty()
            && verifyCode != null && !verifyCode.isEmpty()) {
        sb.append("&decodekey=").append(verifyCode);
    }
    coverUrl = sb.toString();
}

// ✅ 正确 — 使用 Glide 加载封面
Glide.with(context)
        .load(coverUrl)
        .placeholder(android.R.drawable.ic_menu_gallery)
        .error(android.R.drawable.ic_menu_gallery)
        .centerCrop()
        .into(holder.ivCover);

// ❌ 错误 — 直接使用原始 URL（加密设备封面无法解密显示）
vo.coverUrl = file.getCoverPic();

// ❌ 错误 — 写 TODO 不实际加载
holder.ivCover.setImageResource(android.R.drawable.ic_menu_gallery); // TODO: 使用图片加载库
```

### 13. 录像下载必须使用沙盒路径并在 onSuccess 中保存到相册

- **禁止** 将下载路径设为公共目录（如 `Environment.getExternalStoragePublicDirectory()`），Android 10+ 分区存储下会写入失败
- **必须** 使用沙盒路径：`context.getExternalCacheDir() + "/Records/"` 作为下载目标路径
- **必须** 在 `onSuccess(String filePath)` 回调中调用 `saveVideoToGallery()` 将沙盒文件转存到系统相册
- **禁止** 下载成功后只 Toast 提示而不保存到相册（用户在相册中找不到视频）

```java
// ✅ 正确 — 沙盒路径 + onSuccess 中保存到相册
String savePath = context.getExternalCacheDir() + "/Records/" + System.currentTimeMillis() + ".mp4";
EZCloudStreamDownload downloader = new EZCloudStreamDownload(savePath, cloudFile);
downloader.setStreamDownloadCallback(new EZStreamDownloadCallbackEx() {
    @Override
    public void onSuccess(String filePath) {
        // 必须：将沙盒文件转存到系统相册
        File file = new File(filePath);
        long durationSec = (stopTime - startTime) / 1000;
        saveVideoToGallery(context, file, durationSec);
    }
});

// ❌ 错误 — 使用公共目录（Android 10+ 写入失败）
String savePath = Environment.getExternalStoragePublicDirectory(
        Environment.DIRECTORY_MOVIES) + "/playback.mp4";

// ❌ 错误 — 下载成功后只 Toast，不保存到相册
@Override
public void onSuccess(String filePath) {
    Toast.makeText(context, "下载完成", Toast.LENGTH_SHORT).show(); // 用户在相册找不到！
}
```

### 14. 录像下载的 start() 必须在子线程调用

- **禁止** 在主线程调用 `EZCloudStreamDownload.start()` 或 `EZDeviceStreamDownload.start()`
- 下载器内部的 `createDownloadClient` 是 native 方法，在主线程调用会导致 native crash（libezstreamclient.so 崩溃）
- **必须** 在子线程中调用 `downloader.start()`，回调（`onSuccess`/`onError`/`onDownloadingSize`）本身是从子线程回来的，UI 更新需要 `runOnUiThread`
- **必须** 将下载器的创建、配置、start 全部放在同一个子线程中执行（参照 SDK Demo 的 `getTaskManager().submit(() -> { ... })` 模式）

```java
// ✅ 正确 — 整个下载流程在同一个子线程
new Thread(() -> {
    EZDeviceStreamDownload downloader = new EZDeviceStreamDownload(path, serial, cameraNo, file);
    downloader.setSecretKey(verifyCode);
    downloader.setStreamDownloadCallback(callback);
    downloader.start();
}).start();

// ❌ 错误 — 主线程创建或调用 start()
downloader.start(); // native crash
```

### 15. 电子放大必须使用 SDK 的 CustomTouchListener

- **禁止** 自己用 `ScaleGestureDetector` 计算坐标再调用 `setDisplayRegion(long, long, long, long)`（坐标计算容易出错，且不支持拖拽平移）
- **必须** 使用 SDK 提供的 `CustomTouchListener` 设置在 SurfaceView 上
- **必须** 在 SurfaceView 尺寸确定后调用 `setSacaleRect(4.0f, 0, 0, width, height)` 初始化缩放矩形
- 在 `onZoomChange` 回调中调用 `player.setDisplayRegion(true, oRect, curRect, streamId)` 设置缩放区域
- 恢复全画面调用 `player.setDisplayRegion(false, null, null, streamId)`
- **`onDoubleClick` 回调必须为空实现**，不要在双击时调用 setDisplayRegion 或其他操作

```java
// ✅ 正确 — 使用 SDK 的 CustomTouchListener
CustomTouchListener touchListener = new CustomTouchListener() { ... };
surfaceView.setOnTouchListener(touchListener);
touchListener.setSacaleRect(4.0f, 0, 0, width, height);

// onZoomChange 回调中：
if (scale == 1) {
    player.setDisplayRegion(false, null, null, 0);
} else {
    player.setDisplayRegion(true, oRect, curRect, 0);
}

// ❌ 错误 — 自己用 ScaleGestureDetector 计算坐标（容易出错）
mScaleGestureDetector = new ScaleGestureDetector(this, ...);
player.setDisplayRegion(lLeft, lTop, lRight, lBottom); // 坐标范围不确定，容易算错
```

---

## 预览页面 UI 标准组件

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

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

### 交互规则

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

---

## 回放页面 UI 标准组件

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

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

### 交互规则

1. **暂停/恢复**：播放中显示暂停图标，暂停时显示播放图标。
2. **声音开关**：默认关闭。点击切换开/关状态并更新图标。
3. **进度条**：基于当前片段的起止时间计算进度，通过 OSD 定时器每秒更新。拖拽松开后调用 `seekPlayback`。
4. **倍速选择**：
   - **显示条件**：`EZBusinessTool.isSupportPlaybackRate(deviceInfo, cameraInfo) == true` 且播放中时显示倍速按钮
   - **SD 卡录像倍速选项动态渲染**：SD 卡录像的倍速选项根据设备能力集动态渲染，通过 `EZBusinessTool.getSDCardPlaybackRates(deviceInfo, cameraInfo, streamFetchType)` 获取设备支持的倍速列表（返回 null 表示不支持），UI 仅展示设备实际支持的倍速选项（不再写死固定选项）
   - **云录像倍速选项**：云录像倍速选项固定为 `[0.5x, 1x, 2x, 4x, 8x, 16x, 32x]`
   - **UI 交互**：弹出单选对话框，选择后调用 `setPlaybackRate`
   - **内网直连倍速限制判断**：已内置在 `getSDCardPlaybackRates` 逻辑中（内网直连 + 支持内网直连倍速 → 返回 1、4、8）
   - **切换结果**：`setPlaybackRate` 返回 true 成功（更新按钮文本为当前倍率），返回 false 失败（Toast 提示）
   - **状态重置**：停止回放或切换录像类型时，重置倍速为 1x
5. **下载**：下载到沙盒路径，`onSuccess` 中保存到相册（参见强制约束第 13 条）。
6. **日期选择**：弹出 DatePicker，选择后重新查询录像。
7. **Tab 切换**：切换时更新 RecyclerView 的 Adapter 和数据源。
8. **录像列表**：每行 3 个宫格，封面 16:9 比例，显示开始时间和时长。点击片段开始/切换回放。
9. **视频容器比例**：视频播放区域**必须**设置为 16:9 比例（`screenWidth * 9 / 16`），**禁止**使用 `layout_weight` 平分屏幕。与预览页面保持一致。
10. **电子放大**：回放页面**必须**实现电子放大功能（双指缩放），使用 SDK 的 `CustomTouchListener`（参见强制约束第 14 条）。不需要双击恢复全画面，缩小到 1 倍时自动恢复。

---

## 播放器模块 import 参考

```java
// 播放器
import com.videogo.openapi.EZOpenSDK;
import com.videogo.openapi.EZPlayer;
import com.videogo.openapi.EZConstants;                                  // EZPTZCommand、EZPTZAction、EZPlaybackRate 等

// 预览/对讲消息常量（通过 EZConstants 内部类访问）
import com.videogo.openapi.EZConstants.EZRealPlayConstants;              // MSG_REALPLAY_PLAY_SUCCESS、MSG_REALPLAY_VOICETALK_SUCCESS 等

// 回放消息常量（通过 EZConstants 内部类访问）
import com.videogo.openapi.EZConstants.EZPlaybackConstants;              // MSG_REMOTEPLAYBACK_PLAY_SUCCUSS、MSG_REMOTEPLAYBACK_PLAY_FINISH 等

// 错误信息
import com.videogo.errorlayer.ErrorInfo;                                 // 播放失败时的错误对象
import com.videogo.exception.ErrorCode;                                  // 错误码常量

// 设备信息
import com.videogo.openapi.bean.EZDeviceDetailPublicInfo;                // 取流详情（清晰度等）
import com.videogo.openapi.bean.EZVideoQualityInfo;                      // 清晰度信息
import com.videogo.openapi.bean.EZCloudRecordFile;                       // 云存储录像
import com.videogo.openapi.bean.EZDeviceRecordFile;                      // SD 卡录像

// 水印
import com.videogo.stream.EZPlayWaterMarkConfig;                         // 水印配置

// 电子放大
import com.videogo.widget.CustomTouchListener;                           // 手势监听
import com.videogo.widget.CustomRect;                                    // 缩放矩形

// SD 卡录像封面
import com.videogo.remoteplayback.RecordCoverFetcherManager;             // 封面管理器

// 录像下载
import com.videogo.openapi.EZOpenSDKListener;                            // EZStreamDownloadCallback
import com.videogo.stream.EZCloudStreamDownload;                         // 云存储录像下载器
import com.videogo.stream.EZDeviceStreamDownload;                        // SD 卡录像下载器

// 对讲音量回调
import com.ez.player.EZVoiceTalk;                                        // EZVoiceTalk.OnLoudnessListener
```

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

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

```java
// ==================== EZOpenSDK 静态方法（不抛 BaseException） ====================
// 以下方法均为 void 返回，不声明 throws BaseException
EZOpenSDK.showSDKLog(boolean show);                    // void, 无异常
EZOpenSDK.enableP2P(boolean enable);                   // void, 无异常
EZOpenSDK.setDebugStreamEnable(boolean enable);        // void, 无异常
EZOpenSDK.initLib(Application app, String appKey);     // void, 无异常（不抛 BaseException）
EZOpenSDK.logout();                                    // void, 无异常

// ==================== EZOpenSDK 实例方法 ====================
void setAccessToken(String token);                     // void, 无异常
EZPlayer createPlayer(String deviceSerial, int cameraNo);  // 返回 EZPlayer
void setVideoLevel(String deviceSerial, int cameraNo, int videoLevel) throws BaseException;  // 子线程调用
void setVideoLevelAuto(String deviceSerial, int cameraNo, int targetLevel) throws BaseException;
void controlPTZ(String deviceSerial, int cameraNo, EZPTZCommand command, EZPTZAction action, int speed) throws BaseException;
void upgradeDevice(String deviceSerial) throws BaseException;  // ⚠️ 返回 void，不是 boolean
EZProbeDeviceInfoResult probeDeviceInfo(String deviceSerial, String deviceType);  // ⚠️ 不抛异常，错误信息在返回值中

// ==================== EZPlayer 方法 ====================
void setDisplayRegion(long left, long top, long right, long bottom);  // ⚠️ 参数是 long 类型，-1 恢复全画面
void setDisplayRegion(boolean enable, CustomRect oRect, CustomRect curRect, int streamId);  // CustomRect 方式
void enableDeviceAutoVideoLevel();                     // ⚠️ 不抛 BaseException
boolean setPlaybackRate(EZConstants.EZPlaybackRate rate);  // 返回 boolean

// ==================== EZVideoQualityInfo ====================
String getVideoQualityName();                          // ⚠️ 不是 getVideoLevelName()
int getVideoLevel();

// ==================== EZStorageStatus ====================
// ⚠️ 只有以下字段，没有 getTotalCapacity()/getUsedCapacity()
int getIndex();
String getName();
int getStatus();                                       // 0-正常，1-异常，2-未格式化，3-正在格式化
int getFormatRate();

// ==================== EZDefenceStatus 枚举值 ====================
// ⚠️ 不是 EZDefence_On/Off，正确值如下：
EZConstants.EZDefenceStatus.EZDefence_IPC_OPEN         // 布防（开启）
EZConstants.EZDefenceStatus.EZDefence_IPC_CLOSE        // 撤防（关闭）
EZConstants.EZDefenceStatus.EZDefence_ALARMHOST_SLEEP  // 报警主机-睡眠
EZConstants.EZDefenceStatus.EZDefence_ALARMHOST_ATHOME // 报警主机-在家
EZConstants.EZDefenceStatus.EZDefence_ALARMHOST_OUTER  // 报警主机-外出

// ==================== EZVoiceTalk.OnLoudnessListener ====================
// ⚠️ 方法名是 onReportLoudness，不是 onLoudness
public interface EZVoiceTalk.OnLoudnessListener {
    void onReportLoudness(float decibel);              // ⚠️ 不是 onLoudness(float)
}
```

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

### 创建播放器
```java
// 通过设备序列号和通道号创建
EZPlayer player = EZOpenSDK.getInstance().createPlayer(String deviceSerial, int cameraNo);

// 创建子码流播放器
EZPlayer player = EZOpenSDK.getInstance().createPlayer(String deviceSerial, int cameraNo, boolean useSubStream);

// 通过 URL 创建
EZPlayer player = EZOpenSDK.getInstance().createPlayerWithUrl(String url);

// 通过 userId 创建（海康专用）
EZPlayer player = EZOpenSDK.getInstance().createPlayerWithUserId(int userId, int cameraNo, int streamType);
```

### 释放播放器
```java
player.release();
// 或
EZOpenSDK.getInstance().releasePlayer(player);
```

注意：同一设备的不同功能（直播/回放/对讲）需要分别创建独立的播放器。

## 二、取流方式配置

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

### 内网直连开关（默认开启）
```java
EZOpenSDK.enableDirectInner(boolean bEnable);
```

### P2P 开关（默认关闭）
```java
EZOpenSDK.enableP2P(boolean bEnable);
```
- 家用监控场景建议开启
- 多人同时观看场景建议关闭

### 流媒体开关（默认开启，慎用关闭）
```java
EZOpenSDK.enableVTDU(boolean bEnable);
```

### P2P 预连接（优化首帧速度）
```java
// 发起预连接
EZOpenSDK.getInstance().startP2PPreconnect(String deviceSerial);

// 清除预连接
EZOpenSDK.getInstance().clearP2PPreconnect(String deviceSerial);

// 查询预连接是否成功
boolean success = EZOpenSDK.getInstance().isPreConnectionSucceed(String deviceSerial);
```

### 查看取流方式
```java
int type = player.getStreamFetchType();
// 0-流媒体，1-P2P，2-内网直连，4-云存储回放
```

### 获取流量数据
```java
long flow = player.getStreamFlow();
```

## 三、直播预览

### 完整流程
```java
public class EZRealPlayActivity extends Activity implements SurfaceHolder.Callback, Handler.Callback {
    private EZPlayer mEZPlayer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 1. 创建播放器
        mEZPlayer = EZOpenSDK.getInstance().createPlayer(deviceSerial, cameraNo);
        // 2. 设置 Handler 回调
        mEZPlayer.setHandler(mHandler);
        // 3. 设置显示 Surface
        mEZPlayer.setSurfaceHold(surfaceHolder);
        // 4. 设置验证码（设备加密时需要，通过 EZDeviceInfo.getIsEncrypt() 判断）
        // 验证码应在用户首次输入后缓存到本地（如 SharedPreferences），后续直接读取设置，避免重复弹框
        mEZPlayer.setPlayVerifyCode(verifyCode);
        // 5. 开始直播
        mEZPlayer.startRealPlay();
    }

    @Override
    protected void onStop() {
        super.onStop();
        if (mEZPlayer != null) mEZPlayer.stopRealPlay();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (mEZPlayer != null) mEZPlayer.release();
    }

    @Override
    public boolean handleMessage(Message msg) {
        switch (msg.what) {
            case EZRealPlayConstants.MSG_REALPLAY_PLAY_SUCCESS: // 播放成功
                break;
            case EZRealPlayConstants.MSG_REALPLAY_PLAY_FAIL: // 播放失败
                ErrorInfo errorInfo = (ErrorInfo) msg.obj;
                if (errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_NEED
                    || errorInfo.errorCode == ErrorCode.ERROR_INNER_VERIFYCODE_ERROR) {
                    // 需要输入验证码或验证码错误
                    // mEZPlayer.setPlayVerifyCode(newVerifyCode);
                    // mEZPlayer.startRealPlay();
                }
                break;
            case EZConstants.MSG_VIDEO_SIZE_CHANGED: // 画面尺寸变化
                String temp = (String) msg.obj;
                String[] strings = temp.split(":");
                int videoWidth = Integer.parseInt(strings[0]);
                int videoHeight = Integer.parseInt(strings[1]);
                break;
        }
        return false;
    }

    // SurfaceHolder.Callback
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        if (mEZPlayer != null) mEZPlayer.setSurfaceHold(holder);
    }
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        if (mEZPlayer != null) mEZPlayer.setSurfaceHold(holder);
    }
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        if (mEZPlayer != null) mEZPlayer.setSurfaceHold(null); // 必须解绑！
    }
}
```

### 电池设备注意事项
电池设备取流 1 分钟后会进入休眠。需创建定时器每隔 50 秒调用平台接口唤醒设备：
```java
int batteryType = deviceInfo.getSupportInt(322); // 0-不使用电池，1-一块，2-两块
```

### 播放器窗口大小设置
方法一（推荐）：通过 `deviceInfo.getSupportValue(16)` 获取设备宽高比，动态设置
```java
// 获取设备支持的宽高比（如 "16-9"、"4-3"、"1-1"）
String ratioStr = deviceInfo.getSupportValue(16);
if (ratioStr != null && ratioStr.contains("-")) {
    String[] parts = ratioStr.split("-");
    int w = Integer.parseInt(parts[0]);
    int h = Integer.parseInt(parts[1]);
    ConstraintLayout.LayoutParams lp = (ConstraintLayout.LayoutParams) ratioGuide.getLayoutParams();
    lp.dimensionRatio = w + ":" + h;
    ratioGuide.setLayoutParams(lp);
}
// 双目设备强制覆盖为 8:9（两个画面上下排列）
if (isMultiChannelDevice) {
    lp.dimensionRatio = "8:9";
}
```
方法二：固定 16:9 比例（不推荐，无法适配非标比例设备）
方法三：监听 MSG_VIDEO_SIZE_CHANGED 获取实际宽高
方法四：取流成功后调用 `player.getDeviceDetailInfo()` 获取 videoWidth/videoHeight

## 四、录像回放

### 录像查询
```java
// 查询云存储录像
List<EZCloudRecordFile> cloudFiles = EZOpenSDK.getInstance()
    .searchRecordFileFromCloud(deviceSerial, cameraNo, startTime, endTime);

// 查询 SD 卡录像
List<EZDeviceRecordFile> deviceFiles = EZOpenSDK.getInstance()
    .searchRecordFileFromDevice(deviceSerial, cameraNo, startTime, endTime);

// 查询 SD 卡录像（指定录像类型）
List<EZDeviceRecordFile> deviceFiles = EZOpenSDK.getInstance()
    .searchRecordFileFromDeviceEx(deviceSerial, cameraNo, startTime, endTime, videoRecordTypeEx);

// 查询 CVR 录像
List<EZDeviceRecordFile> cvrFiles = EZOpenSDK.getInstance()
    .searchRecordFileFromCVR(deviceSerial, cameraNo, startTime, endTime);
```

### 回放完整流程
```java
// 1. 创建播放器
mPlaybackPlayer = EZOpenSDK.getInstance().createPlayer(deviceSerial, cameraNo);
mPlaybackPlayer.setHandler(handler);
mPlaybackPlayer.setSurfaceEx(textureView.getSurfaceTexture());
mPlaybackPlayer.setPlayVerifyCode(verifyCode);

// 2. 开始回放（云存储）
mPlaybackPlayer.startPlayback(cloudFile);
// 或开始回放（SD 卡）
mPlaybackPlayer.startPlayback(deviceFile);

// 3. 停止回放
mPlaybackPlayer.stopPlayback();

// 4. 释放播放器
mPlaybackPlayer.release();
```

### 回放消息回调
| 消息 | 说明 |
|------|------|
| MSG_REMOTEPLAYBACK_PLAY_SUCCUSS | 回放成功（首帧显示） |
| MSG_REMOTEPLAYBACK_PLAY_START | 播放开始/seek 成功 |
| MSG_REMOTEPLAYBACK_STOP_SUCCESS | 回放停止 |
| MSG_REMOTEPLAYBACK_PLAY_FINISH | 回放完成 |
| MSG_REMOTEPLAYBACK_PLAY_FAIL | 回放失败 |

### 多片段连续播放（最佳实践）

#### 录像列表展示规范

1. 录像列表按时间倒序排列（最新的录像在前）
2. 采用宫格排列方式，每行展示 3 个录像片段
3. 每个录像片段显示录像封面（比例 16:9），封面左上角显示录像开始时间，右上角显示录像时长
4. 云存储录像封面通过 `EZCloudRecordFile.getCoverPic()` 获取 URL 加载。**加密设备的云录像封面需要拼接解密参数**：
   ```java
   String coverUrl = cloudFile.getCoverPic();
   StringBuilder sb = new StringBuilder(coverUrl);
   sb.append("&x=200"); // 缩略图参数
   // 加密设备：拼接 decodekey 参数，值为设备验证码
   String encryption = cloudFile.getEncryption();
   if (encryption != null && !encryption.isEmpty()) {
       sb.append("&decodekey=").append(verifyCode);
   }
   String finalUrl = sb.toString();
   ```
5. SD 卡录像封面通过 `RecordCoverFetcherManager` 获取（详见"SD 卡录像封面"章节）

#### 连续播放原理

核心思路：通过修改录像片段的结束时间，让 SDK 在一次 `startPlayback` 调用中覆盖所有片段的时间范围，实现连续播放。切换片段时使用 `seekPlayback` 而非 `startPlayback`，避免断流。

#### 实现步骤

**步骤 1：首次点击录像片段时，深拷贝并修改结束时间**

用户点击录像片段 A 时：
1. 深拷贝片段 A 得到对象 B（必须复制全部属性，参见 `ezopensdk-models.md` 中的深拷贝规则）
2. 将列表中第一个片段（即最新时间的片段）的结束时间赋值给 B 的 stopTime
3. 用 B 调用 `startPlayback` 开始回放

```java
// 深拷贝云存储录像（推荐 Parcel 方式，自动复制全部属性，参见 ezopensdk-models.md）
EZCloudRecordFile playFile = deepCopy(selectedFile, EZCloudRecordFile.CREATOR);
// 将最新片段的结束时间赋给拷贝对象，扩大回放时间范围
playFile.setStopTime((Calendar) cloudFiles.get(0).getStopTime().clone());
// 启动回放
mPlayer.startPlayback(playFile);
```

**步骤 2：切换录像片段时，使用 seekPlayback**

用户点击其他片段时，不要调用 `startPlayback`（会断流），而是使用 `seekPlayback` 跳转到该片段的开始时间：

```java
// ✅ 正确：使用 seekPlayback 切换片段，保障连续性不断流
Calendar seekTime = Calendar.getInstance();
seekTime.setTimeInMillis(selectedFile.getStartTime().getTimeInMillis());
mPlayer.seekPlayback(seekTime);

// ❌ 错误：不要使用 startPlayback 切换片段，会导致断流
// mPlayer.stopPlayback();
// mPlayer.startPlayback(newFile);
```

**步骤 3：根据 OSD 时间戳动态更新选中片段和进度条**

通过定时器每秒获取 `getOSDTime()`，根据时间戳判断当前播放到了哪个片段，动态更新 UI：

```java
// 定时获取 OSD 时间
Calendar osd = mPlayer.getOSDTime();
long osdMs = osd.getTimeInMillis();

// 遍历录像列表，找到 OSD 时间所属的片段
for (int i = 0; i < recordFiles.size(); i++) {
    long start = recordFiles.get(i).getStartTime().getTimeInMillis();
    long end = recordFiles.get(i).getStopTime().getTimeInMillis();
    if (osdMs >= start && osdMs <= end) {
        // 更新选中状态到第 i 个片段
        updateSelectedIndex(i);
        // 更新进度条（基于当前片段的开始和结束时间）
        int progress = (int) ((osdMs - start) * 1000 / (end - start));
        seekBar.setProgress(progress);
        break;
    }
}
```

**进度条拖拽时的 seek 也基于当前选中片段的时间范围：**

```java
// 根据进度条位置计算目标时间（基于当前选中片段）
long targetMs = currentSegmentStartMs
    + (long) seekBar.getProgress() * (currentSegmentEndMs - currentSegmentStartMs) / 1000;
Calendar seekTime = Calendar.getInstance();
seekTime.setTimeInMillis(targetMs);
mPlayer.seekPlayback(seekTime);
```

#### 云存储录像的特殊处理

云存储录像连续播放的前提是所有片段的 `encryption`、`iStorageVersion`、`cloudType` 一致。如果不一致，需逐个播放，监听 `MSG_REMOTEPLAYBACK_PLAY_FINISH` 回调后播放下一个。

#### 完整流程总结

```
用户点击片段 A（首次）
  → 深拷贝 A 得到 B（复制全部属性）
  → B.stopTime = 最新片段.stopTime
  → startPlayback(B)
  → 启动 OSD 定时器

用户点击片段 C（切换）
  → seekPlayback(C.startTime)  // 不断流
  → 更新当前选中片段和进度条范围

OSD 定时器每秒执行
  → getOSDTime() 获取当前播放时间
  → 匹配所属片段 → 更新选中高亮
  → 计算进度 → 更新进度条
```

## 五、回放控制

### 暂停/恢复
```java
mPlaybackPlayer.pausePlayback();
mPlaybackPlayer.resumePlayback();
```

**⚠️ 必须在子线程调用**：`pausePlayback()` 和 `resumePlayback()` 内部可能触发 SDK CAS 库的锁等待（最长约 10 秒），在主线程调用会导致 ANR 或 Toast 等 UI 组件失效。

```java
// ✅ 正确 — 子线程调用暂停/恢复
new Thread(() -> mPlaybackPlayer.pausePlayback()).start();
new Thread(() -> mPlaybackPlayer.resumePlayback()).start();

// ✅ Kotlin
Thread { ezPlayer?.pausePlayback() }.start()
Thread { ezPlayer?.resumePlayback() }.start()

// ❌ 错误 — 主线程直接调用（可能 ANR 10 秒）
mPlaybackPlayer.resumePlayback(); // 在 onClick 或 runOnUiThread 中直接调用
```

### 进度拖拽（Seek）
```java
Calendar seekTime = Calendar.getInstance();
seekTime.setTime(new Date(trackTime));
mPlaybackPlayer.seekPlayback(seekTime);
```

### 获取当前播放时间戳
```java
Calendar osdTime = mPlaybackPlayer.getOSDTime();
```

### 倍数回放
```java
// 统一使用 setPlaybackRate（云存储和 SD 卡通用）
boolean success = mPlaybackPlayer.setPlaybackRate(EZConstants.EZPlaybackRate rate);

// ❌ setCloudPlaybackRate 已废弃，不要使用
// mPlaybackPlayer.setCloudPlaybackRate(EZCloudPlaybackRate rate);
```
支持的倍率：
| 枚举值 | 说明 |
|--------|------|
| EZ_PLAYBACK_RATE_1 | 1 倍速 |
| EZ_PLAYBACK_RATE_2 | 2 倍速 |
| EZ_PLAYBACK_RATE_4 | 4 倍速 |
| EZ_PLAYBACK_RATE_2_1 | 1/2 倍速 |
| EZ_PLAYBACK_RATE_8 | 8 倍速 |
| EZ_PLAYBACK_RATE_16 | 16 倍速 |
| EZ_PLAYBACK_RATE_32 | 32 倍速（云存储专用） |

注意：倍数回放需设备能力集支持，使用前需判断：
```java
EZBusinessTool.isSupportPlaybackRate(deviceInfo, cameraInfo);
EZBusinessTool.isSupportDirectInnerRelaySpeed(deviceInfo, cameraInfo); // 内网直连下
```

**SD 卡录像倍速选项动态渲染**：SD 卡录像的倍速选项不再写死固定列表，而是根据设备能力集动态获取。通过 `EZBusinessTool.getSDCardPlaybackRates(deviceInfo, cameraInfo, streamFetchType)` 获取设备实际支持的倍速列表，UI 仅展示设备支持的选项。返回 null 表示不支持倍速回放。

```java
/**
 * 获取SD卡录像支持的倍数数组（根据设备能力集动态组装）
 * 规则：
 * 1. 先判断设备是否支持倍数回放(isSupportPlaybackRate)
 *    1.1 如果支持，通过 isSupportPlaybackSmallSpeed 和 getPlaybackMaxSpeed 获取支持倍数
 *        1.1.1 isSupportPlaybackSmallSpeed = true：支持0.5、2倍；= false：不支持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 倍数
 *        - 否：返回 null，表示不支持倍数回放
 */
public static List<EZConstants.EZPlaybackRate> getSDCardPlaybackRates(EZDeviceInfo deviceInfo, EZCameraInfo cameraInfo, int streamFetchType) {
    boolean supportPlaybackRate = isSupportPlaybackRate(deviceInfo, cameraInfo);

    if (supportPlaybackRate) {
        List<EZConstants.EZPlaybackRate> rates = new ArrayList<>();

        // 判断是否支持慢速(0.5倍和2倍)
        boolean supportSmallSpeed = isSupportPlaybackSmallSpeed(deviceInfo, cameraInfo);
        if (supportSmallSpeed) {
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_2_1);
        }

        // 根据 getPlaybackMaxSpeed 获取快速倍数
        int maxSpeed = getPlaybackMaxSpeed(deviceInfo, cameraInfo);
        if (maxSpeed == 1) {
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_1);
        } else if (maxSpeed == 0) {
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_1);
            if (supportSmallSpeed) {
                rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_2);
            }
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_4);
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_8);
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_16);
        } else {
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_1);
            if (supportSmallSpeed) {
                rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_2);
            }
            if (maxSpeed >= 4) {
                rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_4);
            }
            if (maxSpeed >= 8) {
                rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_8);
            }
            if (maxSpeed >= 16) {
                rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_16);
            }
        }
        return rates;
    } else {
        boolean supportDirectInner = isSupportDirectInnerRelaySpeed(deviceInfo, cameraInfo);
        if (streamFetchType == 2 && supportDirectInner) {
            List<EZConstants.EZPlaybackRate> rates = new ArrayList<>();
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_1);
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_4);
            rates.add(EZConstants.EZPlaybackRate.EZ_PLAYBACK_RATE_8);
            return rates;
        }
        return null;
    }
}
```

## 六、录像下载

### 云存储录像下载
```java
EZCloudStreamDownload downloader = new EZCloudStreamDownload(filePath, cloudFile);
downloader.setSecretKey(verifyCode);
// 使用 EZStreamDownloadCallbackEx 可获取下载进度
downloader.setStreamDownloadCallback(new EZOpenSDKListener.EZStreamDownloadCallbackEx() {
    @Override public void onDownloadingSize(long downloadSize) {
        // 云存储下载进度回调（SD 卡不支持）
        int percent = (int) (downloadSize * 100 / cloudFile.getFileSize());
        // 主线程更新 UI（如录像封面右下角显示 "10%"）
    }
    @Override public void onSuccess(String filepath) {
        // 下载完成后转存到系统相册
        File file = new File(filepath);
        if (file.exists() && file.length() > 0) {
            long duration = (cloudFile.getStopTime().getTimeInMillis() - cloudFile.getStartTime().getTimeInMillis()) / 1000;
            EZVideoUtils.saveVideo2Album(context, file, duration);
        }
    }
    @Override public void onError(EZStreamDownloadError code) { }
});
// 双目设备需设置：downloader.setMultiChannelDevice(true);
downloader.start();
// 停止：downloader.stop();
```

**注意**：
- 使用 `EZStreamDownloadCallbackEx`（而非 `EZStreamDownloadCallback`）才能获取 `onDownloadingSize` 进度回调
- `EZStreamDownloadCallbackEx` 是 **abstract class**（Kotlin 中 `object : ...()` 加括号），`EZStreamDownloadCallback` 是 **interface**（Kotlin 中 `object : ...` 不加括号）
- `onDownloadingSize` 仅云存储录像下载支持，SD 卡录像不支持
- SDK 支持多片段并行下载，防重复应在应用层判断（如检查当前片段是否已在下载中）
- 页面退出时需调用 `downloader.stop()` 停止所有进行中的下载任务

### SD 卡录像下载
需先判断设备是否支持：`EZBusinessTool.isSupportSDRecordDownload(deviceInfo, cameraInfo)`

**⚠️ 注意**：云录像和 SD 卡录像下载都推荐统一使用 `EZStreamDownloadCallbackEx`（abstract class，Kotlin 加括号），SDK Demo 中两者均使用该类。`EZStreamDownloadCallback`（interface）也能用但不推荐。

```java
// ✅ 正确 — 整个下载流程在子线程，统一使用 EZStreamDownloadCallbackEx
new Thread(() -> {
    EZDeviceStreamDownload downloader = new EZDeviceStreamDownload(filePath, deviceSerial, cameraNo, deviceFile);
    downloader.setSecretKey(verifyCode); // 有验证码时设置，无则不调用
    downloader.setStreamDownloadCallback(new EZOpenSDKListener.EZStreamDownloadCallbackEx() {
        @Override
        public void onDownloadingSize(long downloadSize) {
            // SD 卡录像不支持进度回调，此方法不会被调用
        }
        @Override
        public void onSuccess(String filepath) {
            File file = new File(filepath);
            long duration = (deviceFile.getStopTime().getTimeInMillis() - deviceFile.getStartTime().getTimeInMillis()) / 1000;
            EZVideoUtils.saveVideo2Album(context, file, duration);
        }
        @Override
        public void onError(EZOpenSDKListener.EZStreamDownloadError code) {
            // 下载失败处理
        }
    });
    // 双目设备需设置：downloader.setMultiChannelDevice(true);
    downloader.start();
}).start();
```

**SD 卡录像下载进度处理**：由于 `onDownloadingSize` 不支持 SD 卡录像，需自行实现假进度（等比时长模拟），n 秒录像约 n 秒完成，每秒递增 `100/n`%，到 99% 后卡住等待 `onSuccess` 回调。

### 录像下载保存到相册

录像下载路径必须为沙盒路径，下载完成后需通过 `ContentResolver` + `MediaStore` 转存到系统相册（与录制视频保存到相册逻辑一致）：

```java
@Override
public void onSuccess(String filepath) {
    // 下载完成后，将沙盒文件转存到系统相册
    File file = new File(filepath);
    if (file.exists() && file.length() > 0) {
        long duration = (stopTime.getTimeInMillis() - startTime.getTimeInMillis()) / 1000;
        EZVideoUtils.saveVideo2Album(context, file, duration);
    }
    // 双目设备会生成第二路文件（文件名后缀 _1.mp4）
    if (isMultiChannelDevice) {
        File f2 = new File(filepath.replace(".mp4", "_1.mp4"));
        if (f2.exists() && f2.length() > 0) {
            EZVideoUtils.saveVideo2Album(context, f2, duration);
        }
    }
}
```

`saveVideo2Album` 完整实现如下（可直接复制使用）：

```java
/**
 * 保存视频文件到系统相册
 * 兼容 Android 9 及以下（传统方式）和 Android 10+（分区存储）
 *
 * @param context  上下文
 * @param file     视频文件（沙盒路径）
 * @param duration 视频时长（秒）
 */
public static void saveVideo2Album(Context context, File file, long duration) {
    if (file == null || !file.exists()) {
        return;
    }

    new Thread(() -> {
        try {
            ContentResolver resolver = context.getContentResolver();
            long currentTimeMs = System.currentTimeMillis();
            long currentTimeSec = currentTimeMs / 1000;

            // 构建完整的 MediaStore 元数据
            ContentValues values = new ContentValues();
            values.put(MediaStore.Video.Media.TITLE, file.getName());
            values.put(MediaStore.Video.Media.DISPLAY_NAME, file.getName());
            values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4");
            values.put(MediaStore.Video.Media.DATE_TAKEN, currentTimeMs);
            values.put(MediaStore.Video.Media.DATE_MODIFIED, currentTimeSec);
            values.put(MediaStore.Video.Media.DATE_ADDED, currentTimeSec);
            values.put(MediaStore.Video.Media.DURATION, duration * 1000); // 毫秒
            values.put(MediaStore.Video.Media.SIZE, file.length());

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                // Android 10+ 分区存储
                values.put(MediaStore.Video.Media.RELATIVE_PATH,
                        Environment.DIRECTORY_DCIM + File.separator + context.getPackageName());

                Uri uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
                if (uri != null) {
                    try (OutputStream os = resolver.openOutputStream(uri);
                         InputStream is = new FileInputStream(file)) {
                        if (os != null) {
                            byte[] buffer = new byte[8192];
                            int bytesRead;
                            while ((bytesRead = is.read(buffer)) != -1) {
                                os.write(buffer, 0, bytesRead);
                            }
                            os.flush();
                        }
                    }
                }
            } else {
                // Android 9 及以下
                values.put(MediaStore.Video.Media.DATA, file.getAbsolutePath());
                Uri uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
                if (uri != null) {
                    context.sendBroadcast(new Intent(
                            Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file)));
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
}
```

### SD 卡录像封面

**重要**：传给 `requestRecordCover` 的录像列表需要按时间**倒序**排列（最新在前）。SDK 内部按此顺序逐个提取封面。

**⚠️ 关键时序约束**：`initFetcher` 是**异步操作**（内部会开线程获取 relay 服务器信息和 ticket，建立与设备的链路），耗时约 1-3 秒。**必须**在 `onCreate` 中提前调用，**禁止**在录像查询完成后才调用 `initFetcher` 并立即调用 `requestRecordCover`（此时 fetcher 尚未初始化完成，`requestRecordCover` 会因 `recordCoverFetcher == null` 直接返回）。

正确时序：
1. `onCreate` → 调用 `initFetcher`（异步建立链路）
2. 录像查询完成 → 调用 `requestRecordCover`（此时 fetcher 已就绪）
3. `onDestroy` → 调用 `stopFetcher`（释放资源）

```java
// ⚠️ 必须在 onCreate 中提前调用（异步操作，需要时间建立链路）
RecordCoverFetcherManager.getInstance().initFetcher(context, deviceSerial, cameraNo,
        new RecordCoverFetcherManager.RecordCoverFetcherInitCallBack() {
            @Override public void onFetcherInitSuccess() { /* 链路建立成功 */ }
            @Override public void onFetcherInitFailed() { /* 链路建立失败 */ }
        });

// 录像查询完成后再请求封面（此时 fetcher 已初始化完成）
List<EZDeviceRecordFile> reversedList = new ArrayList<>(recordFiles);
Collections.reverse(reversedList);
RecordCoverFetcherManager.getInstance().requestRecordCover(reversedList, new RecordCoverFetcherCallBack() {
    @Override public void onGetCoverSuccess(int seq, byte[] bytes) {
        Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
    }
    @Override public void onGetCoverFailed(int errorCode) { }
});

// 页面退出时释放（onDestroy 中调用）
RecordCoverFetcherManager.getInstance().stopFetcher();

// ❌ 错误 — initFetcher 和 requestRecordCover 同步调用（fetcher 还没初始化完成）
RecordCoverFetcherManager.getInstance().initFetcher(context, deviceSerial, cameraNo, null);
RecordCoverFetcherManager.getInstance().requestRecordCover(files, callback); // 无效！fetcher 为 null
```
仅国内支持，海外不支持。需设备能力集支持：`EZBusinessTool.isSupportSdCover(deviceInfo, cameraInfo)`

#### seq 与封面匹配规则

`requestRecordCover` 传入的 `recordFiles` 列表中，每个 `EZDeviceRecordFile` 需通过 `setSeq(i)` 设置索引。回调 `onGetCoverSuccess(int seq, byte[] bytes)` 返回的 `seq` 就是该索引值。

**注意**：`seq` 是请求时手动设置的列表索引，不是 SDK 查询返回的原始 `getSeq()` 值。在 `onBindViewHolder` 中查找封面时，必须用录像对象在原始列表中的位置（`indexOf`）作为 key，不能用 `df.getSeq()`，否则会导致封面错位（所有 item 显示同一张封面）。

```java
// ✅ 正确：用列表索引匹配封面
int sdIndex = sdRecordCache.indexOf(item);
Bitmap bmp = sdIndex >= 0 ? coverCache.get(sdIndex) : null;

// ❌ 错误：getSeq() 是 SDK 原始值，与 requestRecordCover 中 setSeq(i) 的 i 不一致
Bitmap bmp = coverCache.get(df.getSeq());
```


## 七、截图

### 播放器截图（取流过程中）
```java
// 返回 Bitmap（耗时操作，需在子线程）
Bitmap bmp = mEZPlayer.capturePicture();

// 双目设备指定轨道：0-广角，1-云台
Bitmap bmp = mEZPlayer.capturePicture(int streamId);

// 保存到文件
int result = mEZPlayer.capturePicture(String fileNameWithPath);
```

### 水印截图
```java
Bitmap bmp = mEZPlayer.captureRenderPicture(int width, int height);
```
仅支持单目设备。

## 八、录制

### 普通录制（取流过程中）
```java
// 设置录制回调
mEZPlayer.setStreamDownloadCallback(new EZOpenSDKListener.EZStreamDownloadCallback() {
    @Override public void onSuccess(String filepath) { }
    @Override public void onError(EZStreamDownloadError code) { }
});

// 开始录制（路径必须为沙盒路径）
String recordFile = context.getExternalCacheDir() + "/Records/" + System.currentTimeMillis() + ".mp4";
boolean result = mEZPlayer.startLocalRecordWithFile(recordFile);

// 停止录制
mEZPlayer.stopLocalRecord();
```

### 水印录制
```java
// 开始水印录制
boolean result = mEZPlayer.startRenderRecordWithFile(recordFile);

// 停止水印录制
mEZPlayer.stopRenderRecord();
```
仅支持单目设备。页面退出时需在 onStop 中停止录制。

## 九、声音控制

```java
mEZPlayer.openSound();   // 开启声音
mEZPlayer.closeSound();  // 关闭声音
```
取流成功后需手动调用 `openSound()` 声音才会播放。应在收到预览成功（`MSG_REALPLAY_PLAY_SUCCESS`）或回放成功（`MSG_REMOTEPLAYBACK_PLAY_SUCCUSS`）消息后，根据声音开关状态调用：

```java
// 在播放成功回调中设置声音
if (isSoundOpen) player.openSound(); else player.closeSound();
```

### 扬声器/听筒切换
```java
mEZPlayer.setSpeakerphoneOn(true);   // 扬声器模式
mEZPlayer.setSpeakerphoneOn(false);  // 听筒模式
boolean isSpeaker = mEZPlayer.isSpeakerphoneOn();
```

## 十、清晰度

### 获取设备支持的清晰度
取流成功后调用（注意：`EZDeviceDetailPublicInfo` 的属性为 public 字段，直接访问，不要用 getter）：
```java
EZDeviceDetailPublicInfo info = mEZPlayer.getDeviceDetailInfo();
ArrayList<EZVideoQualityInfo> qualityInfos = info.videoQualityInfos; // ✅ 直接访问字段
// info.getVideoQualityInfos() ← ❌ 不存在此方法
```

### 设置清晰度
```java
// 普通设置（耗时操作，子线程调用）
EZOpenSDK.getInstance().setVideoLevel(deviceSerial, cameraNo, videoLevel);
// videoLevel: 0-流畅，1-均衡，2-高清，3-超清，4-极清，5-3K，6-4K
```
设置后需 stopRealPlay → startRealPlay 才能生效。注意：`setVideoLevel` 在子线程调用，`stopRealPlay` 和 `startRealPlay` 必须在主线程调用。

```java
// 完整流程示例
mEZPlayer.stopRealPlay(); // 主线程
new Thread(() -> {
    EZOpenSDK.getInstance().setVideoLevel(deviceSerial, cameraNo, newLevel); // 子线程
    runOnUiThread(() -> mEZPlayer.startRealPlay()); // 回主线程
}).start();
```

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

当设备支持自动清晰度能力时，可在清晰度选择列表中加入【自动】选项。用户选择【自动】后，SDK 会监测网络状况，通过 Handler 回调通知升档/降档消息，开发者再调用 `setVideoLevelAuto` 切换清晰度，整个过程预览不断流。

#### 1. 判断设备是否支持
```java
// 兼容子设备的判断方式（推荐）
boolean support = EZBusinessTool.isSupportDeviceAutoVideolevel(deviceInfo, cameraInfo);

// 或直接通过 EZDeviceInfo 判断
boolean support = deviceInfo.isSupportDeviceAutoVideolevel();
```

#### 2. 开启网络检测开关并重新取流

当用户选择【自动】后，需开启网络检测开关并重新取流。同时将自动清晰度状态缓存到本地，下次取流时恢复。

```java
// 本地缓存自动清晰度状态
SharedPreferences sp = getSharedPreferences("prefs", MODE_PRIVATE);
sp.edit().putBoolean("quality_auto_" + deviceSerial, true).apply();

// 停止播放
previewPlayer.stopRealPlay();
// 延迟后开启网络检测并重新取流
handler.postDelayed(() -> {
    previewPlayer.enableDeviceAutoVideoLevel(); // 开启网络检测开关
    previewPlayer.startRealPlay();              // 重新取流
}, 500);
```

注意：每次 `startRealPlay` 之前，如果当前是自动清晰度模式，都需要调用 `enableDeviceAutoVideoLevel()`，包括：
- 首次取流（surfaceCreated）
- 从后台恢复（onRestart）
- 从回放切回预览（switchToPreview）
- 手动恢复播放（点击播放按钮）

#### 3. 监听升降档消息并切换清晰度

SDK 通过 Handler 回调以下消息：
- `MSG_VIDEO_LEVEL_AUTO_IMPROVE`：网络好，建议升档（切换到更高清晰度）
- `MSG_VIDEO_LEVEL_AUTO_REDUCE`：网络差，建议降档（切换到更低清晰度）

```java
@Override
public boolean handleMessage(Message msg) {
    switch (msg.what) {
        case EZRealPlayConstants.MSG_VIDEO_LEVEL_AUTO_IMPROVE: // 网络好，建议升档
            if (isAutoVideoLevel && !isRecording) { // 录制时不能切换
                int higher = EZBusinessTool.getHigherVideoLevel(currentVideoLevel, deviceDetailPublicInfo);
                if (higher > currentVideoLevel) {
                    setQualityModeAuto(higher);
                }
            }
            break;
        case EZRealPlayConstants.MSG_VIDEO_LEVEL_AUTO_REDUCE: // 网络差，建议降档
            if (isAutoVideoLevel && !isRecording) {
                int lower = EZBusinessTool.getLowerVideoLevel(currentVideoLevel, deviceDetailPublicInfo);
                if (lower < currentVideoLevel) {
                    setQualityModeAuto(lower);
                }
            }
            break;
    }
    return false;
}
```

#### 4. 执行自动清晰度切换

使用 `setVideoLevelAuto`（非强制设置，不断流）而非 `setVideoLevel`（强制设置，需断流重连）：

```java
private void setQualityModeAuto(int targetLevel) {
    new Thread(() -> {
        try {
            EZOpenSDK.getInstance().setVideoLevelAuto(deviceSerial, cameraNo, targetLevel);
            currentVideoLevel = targetLevel;
            runOnUiThread(() -> updateQualityLabel()); // 更新 UI 显示
        } catch (BaseException e) {
            // 失败不用处理（多客户端同时取流时可能失败）
        }
    }).start();
}
```

#### 5. 获取相邻清晰度级别

根据设备支持的清晰度列表，找到比当前级别高/低一级且最接近的清晰度：

```java
// EZBusinessTool 中的辅助方法
public static int getHigherVideoLevel(int currentLevel, EZDeviceDetailPublicInfo detailInfo) {
    if (detailInfo != null && detailInfo.videoQualityInfos != null) {
        int target = -1, minDiff = Integer.MAX_VALUE;
        for (EZVideoQualityInfo q : detailInfo.videoQualityInfos) {
            int level = q.getVideoLevel();
            if (level > currentLevel && (level - currentLevel) < minDiff) {
                target = level; minDiff = level - currentLevel;
            }
        }
        if (target >= 0) return target;
    }
    return currentLevel; // 无更高级别时返回当前值
}

public static int getLowerVideoLevel(int currentLevel, EZDeviceDetailPublicInfo detailInfo) {
    // 逻辑类似，找比当前小且最接近的值
}
```

#### 6. UI 显示规范

- 自动模式时，清晰度按钮显示"自动(当前清晰度)"，如"自动(高清)"
- 非自动模式时，显示具体清晰度名称，如"高清"
- 清晰度选择弹窗中，支持自动清晰度的设备在列表末尾追加"自动"选项

#### 注意事项
- `setVideoLevelAuto` 为非强制设置，两个及以上客户端同时取流时可能失败（返回异常），失败不影响当前播放
- 录制过程中不能调用 `setVideoLevelAuto`，因为分辨率变化会影响转码
- `enableDeviceAutoVideoLevel()` 未调用时，不会收到升降档消息回调
- `deviceDetailPublicInfo` 需在取流成功后通过 `previewPlayer.getDeviceDetailInfo()` 获取

### 性能/画质优先
```java
// startRealPlay 之前调用
mEZPlayer.setVideoQuality(EZConstants.EZVideoQuality.VIDEO_PERFORMANCE_PRIORITY);
mEZPlayer.setVideoQuality(EZConstants.EZVideoQuality.VIDEO_QUALITY_PRIORITY);
```

## 十一、云台控制

### 云台操作
```java
// 推荐：双通道控制（基线客户）
EZOpenSDK.getInstance().controlPTZMix(deviceSerial, cameraNo, command, action, speed);

// 基础控制（私有云客户）
EZOpenSDK.getInstance().controlPTZ(deviceSerial, cameraNo, command, action, speed);

// 8 档速率控制
EZOpenSDK.getInstance().controlPTZEx(deviceSerial, cameraNo, command, action, newSpeed);

// P2P 控制
EZOpenSDK.getInstance().controlPTZViaP2P(deviceSerial, cameraNo, command, action, newSpeed);
```

EZPTZCommand 枚举：
| 枚举值 | 说明 |
|--------|------|
| EZPTZCommandLeft | 向左转 |
| EZPTZCommandRight | 向右转 |
| EZPTZCommandUp | 向上转 |
| EZPTZCommandDown | 向下转 |
| EZPTZCommandZoomIn | 镜头拉近 |
| EZPTZCommandZoomOut | 镜头拉远 |
| EZPTZCommandFocusNear | 焦距变近 |
| EZPTZCommandFocusFar | 焦距变远 |

EZPTZAction 枚举：
- `EZPTZActionSTART` - 开始
- `EZPTZActionSTOP` - 停止

### 云台角度回调
```java
case EZRealPlayConstants.MSG_PTZ_GET_SUCCESS:
    EZDevicePtzAngleInfo info = (EZDevicePtzAngleInfo) msg.obj;
    // info.horStartAng / horEndAng / horCurAng - 水平方向
    // info.verStartAng / verEndAng / verCurAng - 垂直方向
    break;
```

## 十二、画面缩放与拖拽（电子放大）

### 使用 CustomTouchListener（推荐）
```java
CustomTouchListener touchListener = new CustomTouchListener() {
    @Override public boolean canZoom(float scale) { return isPlaying; }
    @Override public boolean canDrag(int direction) { return isPlaying; }
    @Override public void onSingleClick() { /* 显示/隐藏控制栏 */ }
    @Override public void onDoubleClick(View v, MotionEvent e) { /* 双击放大/还原 */ }
    @Override public void onZoom(View view, float scale) { /* 缩放过程中回调 */ }
    @Override public void onZoomChange(View view, float scale, CustomRect oRect, CustomRect curRect) {
        try {
            if (scale == 1) {
                mEZPlayer.setDisplayRegion(false, null, null);
            } else {
                mEZPlayer.setDisplayRegion(true, oRect, curRect);
            }
        } catch (Exception e) {
            // setDisplayRegion 可能抛出 PlaySDKException
        }
    }
    @Override public void onDrag(int direction, float dx, float dy) { /* 拖拽过程中回调 */ }
    @Override public void onEnd(View view, int mode) { /* 手势结束回调 */ }
};
surfaceView.setOnTouchListener(touchListener);

// 初始化缩放矩形（SurfaceView 尺寸确定后调用）
touchListener.setSacaleRect(4.0f, 0, 0, width, height);
```

### 直接使用坐标
```java
// 放大到指定区域
mEZPlayer.setDisplayRegion(left, top, right, bottom);

// 恢复全画面
mEZPlayer.setDisplayRegion(-1, -1, -1, -1);

// 双目设备指定轨道
mEZPlayer.setDisplayRegion(left, top, right, bottom, streamId);
```

注意：屏幕旋转后必须重新调用 setSacaleRect。

## 十三、智能分析
```java
// 开启/关闭智能分析画框（预览成功后调用）
mEZPlayer.setIntelAnalysis(boolean enable);
```

## 十四、水印

### 设置水印
```java
EZPlayWaterMarkConfig config = new EZPlayWaterMarkConfig();
config.fontArray = new String[]{"水印信息：", "杭州萤石网络有限公司"};
config.fontAlignment = EZWaterMarkTextAlignment.EZWaterMarkTextAlignmentCenter;
config.fontRotateAngle = 45f;
config.fontColorAdapt = true;
config.windowAdaptMode = EZWaterMarkWindowAdaptMode.EZWaterMarkWindowAdaptModeFontRowColumn;

// 取流成功后设置
mEZPlayer.setWaterMarkFont(config);

// 清除水印
mEZPlayer.clearWaterMarkFont();
```
仅支持单目设备。

EZPlayWaterMarkConfig 主要属性：
| 属性 | 默认值 | 说明 |
|------|--------|------|
| fontArray | 空数组 | 水印文本数组 |
| fontWidth / fontHeight | 35 | 字体宽高（最小 15） |
| fontSpace | 1.2f | 行间距倍数 |
| fontRotateAngle | 0f | 顺时针旋转角度 [0,360] |
| fontColorAdapt | false | 字体颜色自适应（深色背景白字，浅色背景黑字） |
| red / green / blue | 0 | 字体颜色 RGB [0,255] |
| alpha | 100 | 透明度 [0,100] |
| startPosX / startPosY | 0f | 起始位置比例 [0,1] |
| fillFullScreen | false | 是否全屏水印 |

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

视频类门锁设备预览时，SDK 回调开门 token：
```java
case EZRealPlayConstants.MSG_PRIVATE_TOKEN_GET_SUCCESS:
    EZPMPlayPrivateTokenInfo tokenInfo = (EZPMPlayPrivateTokenInfo) msg.obj;
    String token = tokenInfo.getToken();
    // 使用 token + 平台接口实现远程开门
    break;
```
需确认设备的远程开锁开关已打开。

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

### 硬解开关
```java
// startRealPlay 之前调用，默认软解
mEZPlayer.setHardDecode(true);

// 获取当前解码类型（播放后调用）：1-软解，2-硬解，0-出错
int type = mEZPlayer.getDecodeType();
```

### 画面旋转
```java
mEZPlayer.setPlayerViewRotation(EZConstants.EZPlayerViewRotationAngle.EZ_PLAYER_VIEW_ROTATION_ANGLE_0);
// EZ_PLAYER_VIEW_ROTATION_ANGLE_0 / EZ_PLAYER_VIEW_ROTATION_ANGLE_90 / EZ_PLAYER_VIEW_ROTATION_ANGLE_180 / EZ_PLAYER_VIEW_ROTATION_ANGLE_270
```
旋转 90°/270° 时需调整播放器尺寸。仅支持单目设备。

### 平滑播放
```java
// startRealPlay 之前调用，仅支持单目
mEZPlayer.enableSmoothPlay(EZConstants.EZSmoothPlayMode mode);
```

### 纯音频播放
```java
// startPlayback 之前调用，非可视报警盒设备专用
mEZPlayer.enablePureAudio();
```

### 鱼眼矫正
```java
mEZPlayer.setSurfaceHolds(surfaceHolders);
mEZPlayer.openFecCorrect(fecCorrectType, fecPlaceType);
// 手势处理
mEZPlayer.onFecTouchDown(index, x, y);
mEZPlayer.onFecTouchMove(index, x, y);
mEZPlayer.onFecTouchUp(index);
mEZPlayer.onFecTouchStartScale(index, distance);
mEZPlayer.onFecTouchScale(index, scale, maxScale);
```

### 原始码流回调（调试用）
```java
mEZPlayer.setOriginDataCallback(new EZOpenSDKListener.OriginDataCallback() {
    @Override public void onData(int dataType, byte[] data, int len) { }
});
```

### PS 转 MP4
```java
boolean success = mEZPlayer.tryTransPsToMp4(psFilePath, mp4FilePath);
boolean success = mEZPlayer.tryTransPsToMp4(psFilePath, mp4FilePath, verifyCode);
```

### AI 回放
```java
mPlaybackPlayer.startAIPlayback(deviceFile);
```

### 播放器禁用 P2P
```java
// 全局 P2P 开启时，单个播放器禁用 P2P
mEZPlayer.setPlayerDisableP2P();
```

### 窗口刷新
```java
// 播放器窗口大小变更时调用，减少黑屏时间
mEZPlayer.refreshPlay();
mEZPlayer.refreshPlay(int streamId);
```

### 清除取流缓存
```java
EZOpenSDK.getInstance().clearStreamInfoCache();
```

## 十七、告警消息

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

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

| 组件 | 说明 | 数据来源 |
|------|------|----------|
| **告警图片** | 左侧缩略图（圆角裁切，centerCrop） | `EZAlarmInfo.getAlarmPicUrl()`，需通过 Glide 加载并处理加密参数 |
| **告警名称** | 设备名称或告警类型 | `EZAlarmInfo.getDeviceName()` / `EZAlarmInfo.getAlarmName()` |
| **告警时间** | 告警发生时间 | `EZAlarmInfo.getAlarmStartTime()` |
| **未读标记** | 红色圆点标识未读 | `EZAlarmInfo.getIsRead()` == 0 表示未读 |

### 告警消息开关（布防）
```java
EZOpenSDK.getInstance().setDefence(deviceSerial, EZConstants.EZDefenceStatus.EZDefence_IPC_OPEN);
```

### 获取告警消息列表
```java
List<EZAlarmInfo> alarms = EZOpenSDK.getInstance()
    .getAlarmList(deviceSerial, pageIndex, pageSize, beginTime, endTime);
// deviceSerial 为 null 时查询整个账户下的告警
```

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

告警图片加载是告警列表页面的核心功能，**必须在列表 Item 中展示告警缩略图**。Android 端使用 Glide 加载图片 URL，加密图片通过 URL 拼接 `decodekey` 参数由服务端解密返回。

#### 加载规则（与云录像封面图加载一致）

1. 使用 Glide 加载 `alarmPicUrl`
2. URL 拼接缩略图参数 `&x=200`（与云录像封面一致）
3. 加密图片拼接 `&decodekey=密钥` 参数（服务端解密后返回明文图片数据）
4. 设置 placeholder 和 error 占位图
5. 使用 `DiskCacheStrategy.ALL` 缓存策略

#### 根据 crypt 字段判断加密方式

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

| crypt 值 | 含义 | decodekey 来源 | 说明 |
|-----------|------|----------------|------|
| 0 | 不确定 | 需解析 URL 中 `isEncrypted` 参数判断 | isEncrypted=1 时使用设备验证码 |
| 1 | 设备加密 | 设备验证码（从本地 SharedPreferences 缓存读取） | 验证码由预览页面取流成功后缓存 |
| 2 | 平台加密 | `EZAlarmInfo.getChecksum()` | SDK 返回的平台加密密钥 |

#### 完整代码示例

```java
// 告警图片加载（参照云录像封面图加载规则）
String picUrl = alarmInfo.getAlarmPicUrl();
if (!TextUtils.isEmpty(picUrl)) {
    // 1. 拼接缩略图参数
    String loadUrl = picUrl;
    if (!loadUrl.contains("&x=")) {
        loadUrl = loadUrl + "&x=200";
    }

    // 2. 加密图片拼接 decodekey 参数
    if (alarmInfo.getIsEncrypt() == 1) {
        String decodeKey = null;
        if (alarmInfo.getCrypt() == 2) {
            // 平台加密：使用 checksum
            decodeKey = alarmInfo.getChecksum();
        } else {
            // 设备加密（crypt=1）：从本地缓存获取验证码
            SharedPreferences prefs = context.getSharedPreferences(
                    "verify_code_prefs", Context.MODE_PRIVATE);
            decodeKey = prefs.getString("verify_code_" + alarmInfo.getDeviceSerial(), null);
        }
        if (!TextUtils.isEmpty(decodeKey) && !loadUrl.contains("&decodekey=")) {
            loadUrl = loadUrl + "&decodekey=" + decodeKey;
        }
    }

    // 3. 使用 Glide 加载
    Glide.with(context)
            .load(loadUrl)
            .placeholder(android.R.drawable.ic_menu_gallery)
            .error(android.R.drawable.ic_menu_gallery)
            .diskCacheStrategy(DiskCacheStrategy.ALL)
            .centerCrop()
            .into(imageView);
} else {
    imageView.setImageResource(android.R.drawable.ic_menu_gallery);
}
```

#### 验证码缓存机制

设备验证码由预览页面（PreviewActivity）取流成功后缓存到 SharedPreferences：

```java
// 预览页面取流成功后缓存验证码
SharedPreferences prefs = context.getSharedPreferences("verify_code_prefs", Context.MODE_PRIVATE);
prefs.edit().putString("verify_code_" + deviceSerial, verifyCode).apply();

// 告警列表页面读取缓存验证码
String cachedCode = prefs.getString("verify_code_" + deviceSerial, null);
```

#### 备用方案：本地解密（适用于服务端不支持 decodekey 参数的场景）

当服务端不支持通过 URL 参数解密时，需要先下载加密图片数据，再调用 SDK 本地解密：

```java
// 下载加密图片数据后，使用 SDK 解密
byte[] decrypted = EZOpenSDK.getInstance().decryptData(inputData, verifyCode, cryptType);
// cryptType: 1-设备加密（verifyCode 为设备验证码），2-平台加密（verifyCode 为 checksum）
Bitmap bitmap = BitmapFactory.decodeByteArray(decrypted, 0, decrypted.length);
```

也可通过 Glide 自定义 `ResourceDecoder` 实现加载过程中自动解密（参考 SDK Demo 中 `EZUtils.loadImage` 方法的实现）。

#### 注意事项

- `alarmPicUrl` 是 String 类型的完整 URL，可直接传给 Glide
- 设备加密验证码通过 SharedPreferences（key: `verify_code_` + deviceSerial）获取本地缓存
- 如果缓存中无验证码（用户从未在预览页输入过），加密图片将无法解密，显示 error 占位图
- Glide 自动处理图片缓存和 RecyclerView 复用问题（无需手动管理 Cell 复用导致的图片错位）
- 建议为 ImageView 设置灰色占位背景或 placeholder，图片加载前有视觉反馈

### 告警图片解密
```java
// 设备加密
byte[] decrypted = EZOpenSDK.getInstance().decryptData(inputData, verifyCode);

// 指定加密类型：1-设备加密，2-平台加密
byte[] decrypted = EZOpenSDK.getInstance().decryptData(inputData, verifyCode, cryptType);
```
alarmPicUrl 为 String 类型，需先转为 byte[] 再调用解密。cryptType 对应 EZAlarmInfo 的 crypt 属性。

### 未读消息数
```java
int count = EZOpenSDK.getInstance().getUnreadMessageCount(deviceSerial, EZMessageType.EZMessageTypeAlarm);
```

### 设置告警已读
```java
EZOpenSDK.getInstance().setAlarmStatus(alarmIdList, EZAlarmStatus.EZAlarmStatusRead);
```

### 删除告警
```java
EZOpenSDK.getInstance().deleteAlarm(alarmIdList);
```

## 十八、事件回放

### 告警事件录像回放

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

```java
// 1. 计算录像时间范围
// 录像开始时间 = 告警开始时间 - preTime（秒）
Calendar beginTime = (Calendar) alarmStartTime.clone();
beginTime.add(Calendar.SECOND, -alarmInfo.getPreTime());
// 录像结束时间 = 告警开始时间 + delayTime（秒）
Calendar endTime = (Calendar) alarmStartTime.clone();
endTime.add(Calendar.SECOND, alarmInfo.getDelayTime());

// 2. 先查 SD 卡录像
List<EZDeviceRecordFile> deviceFiles = EZOpenSDK.getInstance()
    .searchRecordFileFromDevice(deviceSerial, cameraNo, beginTime, endTime);

// 3. SD 卡无录像则查云存储
if (deviceFiles == null || deviceFiles.isEmpty()) {
    List<EZCloudRecordFile> cloudFiles = EZOpenSDK.getInstance()
        .searchRecordFileFromCloud(deviceSerial, cameraNo, beginTime, endTime);
}

// 4. 播放查询到的录像片段（流程同录像回放）
```


## 十九、隐私遮蔽

### 能力集判断

```java
// 判断设备是否支持隐私遮蔽（能力集位 388）
boolean supportPrivacy = deviceInfo.getSupportInt(388) == 1;
```

### 查询隐私遮蔽状态

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

```java
// GET 请求，Header 传 accessToken 和 deviceSerial
URL url = new URL("https://open.ys7.com/api/v3/device/video/switch/status?type=7");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("accessToken", token);
conn.setRequestProperty("deviceSerial", deviceSerial);
// 返回 JSON 中 data.enable == 1 表示隐私遮蔽已开启
```

### 切换隐私遮蔽

```java
// PUT 请求
URL url = new URL("https://open.ys7.com/api/v3/device/video/switch/status");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("PUT");
conn.setRequestProperty("accessToken", token);
// Body: type=7&deviceSerial=xxx&enable=1（开启）或 enable=0（关闭）
```

### 注意事项

- 隐私遮蔽开启时不能预览，需停止播放并显示覆盖层
- 隐私遮蔽开启时点击播放按钮应 Toast 提示，不执行播放
- 关闭隐私遮蔽后需恢复预览
- 设备列表页面可批量查询在线设备的隐私状态（仅查询 `getSupportInt(388) == 1` 的在线设备）
- 进入预览页面时应从设备列表传入初始隐私状态，避免异步查询的时序问题导致先显示 loading 再显示遮蔽层


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

### 触发条件

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

```java
isCompactMode = isMultiChannelDevice;
if (!isCompactMode) {
    ConstraintLayout.LayoutParams checkLp = (ConstraintLayout.LayoutParams) binding.ratioGuide.getLayoutParams();
    String ratio = checkLp.dimensionRatio; // 如 "9:16"
    if (ratio != null && ratio.contains(":")) {
        String[] rp = ratio.split(":");
        float rw = Float.parseFloat(rp[0]);
        float rh = Float.parseFloat(rp[1]);
        // 宽高比 <= 1 时进入紧凑模式
        if (rw / rh <= 1.0f) {
            isCompactMode = true;
        }
    }
}
```

### 紧凑模式布局调整

1. **播放器窗口缩放**：高度限制为屏幕 60%，宽度等比例缩小，居中显示，容器背景黑色
2. **导航栏**：隐藏顶部导航栏（btn_back + tv_title 的父容器），在播放窗口左上角添加浮动返回按钮 + 设备名称胶囊（半透明黑底）
3. **设备名称联动**：设备名称随工具栏联动显隐（工具栏隐藏时名称也隐藏，返回按钮始终可见）
4. **功能栏**：如果设备同时支持隐私遮蔽和云台，功能栏显示 5 个按钮（隐私、云台、对讲、截图、录制），用 HorizontalScrollView 包裹支持滑动
5. **云台面板**：使用覆盖面板（ptz_overlay），右上角有关闭按钮
6. **对讲面板**：使用覆盖面板（talk_overlay），从播放器底部到页面底部，挂断后不恢复云台面板

### 竖屏全屏

紧凑模式下（竖屏比例设备，如 9:16）点击全屏按钮，不旋转屏幕，而是竖屏全屏：

- 播放窗口宽度撑满屏幕，高度按设备原始宽高比等比例缩放
- 播放窗口垂直居中，上下多余空间黑屏（父容器 gravity=center，背景黑色）
- 工具栏显示在播放窗口底部
- 其他 UI 全部隐藏（导航栏、功能按钮、底部 tab）
- 左上角显示返回按钮
- 全屏图标切换为缩小图标

退出竖屏全屏时恢复紧凑模式的缩放尺寸和所有 UI。

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

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

- 调用 `setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)` 旋转屏幕
- 播放器撑满整个 Activity（`MATCH_PARENT` × `MATCH_PARENT`）
- 隐藏状态栏和导航栏（沉浸式全屏）
- 其他 UI 全部隐藏
- 全屏按钮保持在播放器右下角，图标切换为缩小图标
- 退出时调用 `setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)` 恢复竖屏

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

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

```java
private String getDeviceDisplayName() {
    if (deviceInfo != null && deviceInfo.getDeviceName() != null && !deviceInfo.getDeviceName().isEmpty()) {
        return deviceInfo.getDeviceName();
    }
    String category = deviceInfo != null ? deviceInfo.getCategory() : null;
    return String.format("%s(%s)", category != null ? category : "", deviceSerial);
}
```
