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

## 播放器模块 import 参考

```typescript
import {
  EZOpenSDK,
  EZPlayer,
  EZPlayerCallback,
  EZPlayerAdditionalInfoCallback,
  EZDeviceInfo,
  EZCameraInfo,
  EZError,
  EZErrorCode,
  EZMessageCode,
  EZCloudRecordFile,
  EZDeviceRecordFile,
  EZDeviceDetailPublicInfo,
  EZVideoQualityInfo,
  EZVideoLevelType,
  EZPlaybackRate,
  EZPTZCommand,
  EZPTZAction,
  EZDevicePtzAngleInfo,
  EZPMPlayPrivateTokenInfo,
  EZRecordCoverFetcherManager,
  EZCloudStreamDownload,
  EZDeviceStreamDownload,
  EZVideoRecordType,
  EzvizSupportKit
} from '@ohos/EZOpenSDK'
import EZBusinessTool from '../businesstool/EZBusinessTool'
```

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

以下规则在生成播放器相关代码时**必须严格遵守**：

### 1. 所有 async SDK 方法必须 await

- **禁止** 不加 await 调用 `startRealPlay`、`stopRealPlay`、`releasePlayer` 等异步方法
- **必须** 在调用链路上所有函数标记 async 并使用 await

```typescript
// ✅ 正确
async onPageShow() {
  await this.player?.startRealPlay()
}

// ❌ 错误 — 不加 await
onPageShow() {
  this.player?.startRealPlay() // 返回 Promise 未等待
}
```

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

- **禁止** 从 `EZCameraInfo.videoQualityInfos` 获取清晰度列表（静态数据，可能不准确）
- **必须** 在取流成功后通过 `player.getDeviceDetailInfo` 获取

```typescript
// ✅ 正确 — 取流成功后从播放器获取
if (messageCode == EZMessageCode.PLAYER_REALPLAY_START) {
  this.player?.getDeviceDetailInfo((info) => {
    let qualityInfos = info?.videoQualityInfos // 准确的清晰度列表
  })
}
```

### 3. 云台控制推荐使用 controlPTZMix

- **推荐** 使用 `EZOpenSDK.controlPTZMix`（http+p2p 双通道，响应更快）
- `controlPTZEx` 为基础方法，私有云客户使用

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

- 预览/回放页面启动时，**必须** 从本地缓存（PreferenceUtil）读取验证码
- 缓存 key：设备序列号（deviceSerial）
- 预览页面用户输入验证码且取流成功后缓存到本地，回放页面直接读取该缓存

```typescript
// ✅ 正确
let verifyCode = PreferenceUtil.getString(getContext(), deviceSerial)
if (verifyCode) {
  this.player?.setPlayVerifyCode(verifyCode)
}
```

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

- **禁止** 直接将 player 置 null 而不释放
- **必须** 使用 `await EZOpenSDK.releasePlayer(player)` 释放

```typescript
// ✅ 正确
await EZOpenSDK.releasePlayer(this.player)
this.player = null
```

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

- **禁止** 在 `stopLocalRecordExt` 回调之前读取/操作录制文件
- **必须** 在回调中确认录制完成后再转存相册

```typescript
// ✅ 正确
await this.player?.stopLocalRecordExt(async (result) => {
  if (result) {
    await EZMediaFileUtil.saveMediaToAlbum(filePath, photoAccessHelper.PhotoType.VIDEO)
  }
})
```

### 7. 页面生命周期必须正确管理播放器

- **必须** 在 `onPageShow` 开始取流，`onPageHide` 停止取流
- **必须** 在 `aboutToDisappear` 释放播放器
- **必须** 在 `onPageHide` 中停止正在进行的录制和对讲

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

- **禁止** 定义 `const PLAYER_REALPLAY_START = 1` 等自定义数字常量
- **必须** 使用 SDK 中的 `EZMessageCode` 枚举值

```typescript
// ✅ 正确
if (messageCode == EZMessageCode.PLAYER_REALPLAY_START) { }

// ❌ 错误
const MSG_PLAY_SUCCESS = 1
if (messageCode == MSG_PLAY_SUCCESS) { }
```

### 9. 双目设备必须设置两个 XComponent

- 双目设备（`EzvizSupportKit.isSupportMultiChannel`）必须创建两个 XComponent
- 第一个 `setPlayerView(surfaceId)` streamId 默认 0
- 第二个 `setPlayerView(surfaceId2, 1)` streamId = 1

---

## 预览页面 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` 恢复取流，取流成功后移除封面。
2. **声音开关**：取流成功后声音默认开启。对讲期间非国标设备自动关闭预览声音，对讲结束后恢复。
3. **清晰度选择**：弹出 SheetDialog 清晰度列表（从 `player.getDeviceDetailInfo` 获取）。切换流程：`setVideoLevel` → `stopRealPlay` → `startRealPlay`。
4. **截图**：调用 `capturePicture()`，通过 `EZMediaFileUtil.saveMediaToAlbum` 保存到相册，保存后 Toast 提示。
5. **录制**：开始录制后显示红色计时器，停止后在回调中转存到相册。录制期间禁止切换清晰度。
6. **云台控制**：Touch Down 发送 `EZPTZActionStart`，Touch Up 发送 `EZPTZActionStop`。通过 `EZBusinessTool.isSupportPTZ` 判断是否显示。预览未成功时禁用。
7. **对讲**：通过 `EZBusinessTool.isSupportTalk` 判断是否支持及对讲类型。对讲前需申请麦克风权限。
8. **全屏**：全屏按钮浮动在播放器画面右下角。

---

## 回放页面 UI 标准组件

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

| 序号 | 控件 | 功能说明 | 对应 SDK 方法 | 显示条件 |
|------|------|----------|--------------|----------|
| 1 | **暂停/恢复** | 暂停或恢复回放 | `pausePlayback` / `resumePlayback` | 始终显示 |
| 2 | **声音开关** | 开启或关闭回放声音（默认开启） | `openSound` / `closeSound` | 始终显示 |
| 3 | **进度条** | 显示当前播放进度，支持拖拽 seek | `seekPlayback` / `getOSDTime` | 始终显示 |
| 4 | **倍速选择** | 切换回放倍速（根据设备能力集动态渲染选项） | `setPlaybackRate` | 设备支持倍速时显示 |
| 5 | **下载** | 下载当前录像片段到相册 | `EZCloudStreamDownload` / `EZDeviceStreamDownload` | 始终显示 |
| 6 | **日期选择** | 切换查询日期 | 重新查询录像 | 始终显示 |
| 7 | **云存储/SD 卡切换** | 切换录像来源类型 | — | 始终显示 |
| 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 交互**：点击倍速按钮展开选择面板（横向排列倍速选项，当前选中项高亮），选择后调用 `player.setPlaybackRate(rate, 0)`
   - **SD 卡倍速限制判断**：已内置在 `getSDCardPlaybackRates` 逻辑中（内网直连 + 支持内网直连倍速 → 返回 1、4、8）
   - **切换结果**：`setPlaybackRate` 返回 true 成功（更新按钮文本为当前倍率），返回 false 失败（Toast 提示）
   - **状态重置**：停止回放或切换录像类型时，重置倍速为 1x 并关闭选择面板
5. **下载**：下载当前选中的录像片段到相册。云存储使用 `EZCloudStreamDownload`，SD 卡使用 `EZDeviceStreamDownload`。下载到沙盒后通过 `EZMediaFileUtil.saveMediaToAlbum` 转存相册。云存储支持进度回调（`onDownloadingSize`），SD 卡不支持。
6. **日期选择**：弹出 DatePickerDialog，选择后重新查询录像。不能选择未来日期。
7. **云存储/SD 卡切换**：切换时停止当前播放并重新查询对应类型的录像。
8. **录像列表**：Grid 布局每行 3 个，封面 16:9 比例，显示时间和时长。SD 卡录像封面通过 `EZRecordCoverFetcherManager` 获取。点击片段切换回放（使用 seekPlayback 跳转）。
9. **连续回放**：查询到录像后将第一个片段 deepCopy，stopTime 设为最后一个片段的 stopTime，实现连续播放。OSD 定时器每秒检测当前播放位于哪个片段，自动更新选中状态。

---

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

### 创建播放器
```typescript
// 通过设备序列号和通道号创建
let player = EZOpenSDK.createPlayer(deviceSerial, cameraNo)
```

### 释放播放器
```typescript
// 释放播放器必须使用 EZOpenSDK 类方法
await EZOpenSDK.releasePlayer(player)
```

**重要**：
- 同一设备的不同功能（直播/回放/对讲）需要分别创建独立的播放器，播放器功能是单一的
- 释放播放器使用 `await EZOpenSDK.releasePlayer(player)`
- 页面退出（`aboutToDisappear`）时必须释放播放器

## 二、取流方式配置

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

### 内网直连（默认开启）
```typescript
EZOpenSDK.enableDirectInner(true)
```

### P2P 开关（默认关闭）
```typescript
EZOpenSDK.enableP2P(true)
```

### 流媒体开关（默认开启，慎用关闭）
```typescript
EZOpenSDK.enableVTDU(true)
```

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

## 三、直播预览

### 完整流程
```typescript
@Entry
@Component
struct EZRealPlayPage {
  player: EZPlayer | null = null
  xComponentController: XComponentController = new XComponentController()

  aboutToAppear() {
    // 1. 创建播放器
    this.player = EZOpenSDK.createPlayer(deviceSerial, cameraNo)
    // 2. 设置回调
    this.player.setPlayerCallback(this.playerCallback)
    // 3. 设置验证码（加密设备需要）
    if (deviceInfo.isEncrypt) {
      let verifyCode = PreferenceUtil.getString(getContext(), deviceSerial)
      this.player.setPlayVerifyCode(verifyCode)
    }
  }

  async onPageShow() {
    // 4. 开始直播（页面可见时取流）
    await this.player?.startRealPlay()
  }

  async onPageHide() {
    // 5. 停止直播（页面不可见时停止）
    await this.player?.stopRealPlay()
  }

  async aboutToDisappear() {
    // 6. 释放播放器
    await EZOpenSDK.releasePlayer(this.player)
  }

  private playerCallback: EZPlayerCallback = {
    didReceivedMessage: async (messageCode: number) => {
      if (messageCode == EZMessageCode.PLAYER_REALPLAY_START) {
        // 预览播放成功
      } else if (messageCode == EZMessageCode.PLAYER_NET_CHANGED) {
        // 网络变更，需要停止后重新取流
        await this.player?.stopRealPlay()
        await this.player?.startRealPlay()
      }
    },
    didPlayFailed: (error: EZError) => {
      if (error.code == EZErrorCode.EZ_SDK_NEED_VALIDATECODE) {
        // 需要验证码
      } else if (error.code == EZErrorCode.EZ_SDK_VALIDATECODE_NOT_MATCH) {
        // 验证码错误
      }
    }
  }

  build() {
    XComponent({
      id: 'PreviewPlayerComponentID',
      type: XComponentType.SURFACE,
      controller: this.xComponentController
    })
      .onLoad(() => {
        this.xComponentController.setXComponentSurfaceSize({ surfaceWidth: 1920, surfaceHeight: 1080 })
        this.player?.setPlayerView(this.xComponentController.getXComponentSurfaceId())
      })
      .width('100%')
      .aspectRatio(16 / 9)
      .backgroundColor(Color.Black)
  }
}
```

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

加密设备（`isEncrypt == 1`）取流前必须调用 `setPlayVerifyCode` 设置验证码：
```typescript
// 从本地缓存读取验证码
let verifyCode = PreferenceUtil.getString(getContext(), deviceSerial)
if (verifyCode) {
  player.setPlayVerifyCode(verifyCode)
}

// 用户输入验证码后缓存（取流成功后保存）
PreferenceUtil.saveString(getContext(), deviceSerial, inputVerifyCode)
```

### 播放器窗口大小设置

方法一（推荐）：固定 16:9 比例
```typescript
XComponent({ ... }).aspectRatio(16 / 9)
```

方法二：通过画面尺寸变更回调获取
```typescript
if (messageCode == EZMessageCode.PLAYER_VIDEO_SIZE_CHANGED) {
  let width = await this.player?.getVideoWidth()
  let height = await this.player?.getVideoHeight()
}
```

方法三：通过能力集获取
```typescript
let videoRatio = EzvizSupportKit.getSupportValue(16, deviceInfo.abilities) // "16-9"
```

### 电池设备注意事项

电池设备取流 1 分钟后会进入休眠。需创建定时器每隔 50 秒调用平台接口唤醒设备：
```typescript
let batteryType = EzvizSupportKit.getSupportInt(322, deviceInfo.abilities)
// batteryType > 0 时需要定时唤醒
```

## 四、录像回放

### 录像查询
```typescript
// 查询云存储录像
EZOpenSDK.searchRecordFileFromCloud(deviceSerial, cameraNo, beginTime, endTime, (records, error) => {
})

// 查询 SD 卡录像
EZOpenSDK.searchRecordFileFromDevice(deviceSerial, cameraNo, beginTime, endTime, recordType, (records, error) => {
})
```

### 回放完整流程
```typescript
// 创建播放器
this.player = EZOpenSDK.createPlayer(deviceSerial, cameraNo)
this.player.setPlayerCallback(this.playerCallback)
this.player.setPlayVerifyCode(verifyCode)

// 开始回放（云存储）
await this.player.startPlaybackFromCloud(cloudFile)
// 或开始回放（SD 卡）
await this.player.startPlaybackFromDevice(deviceFile)

// 停止回放
await this.player.stopPlayback()
```

### 回放消息回调

| messageCode | 常量 | 含义 |
|-------------|------|------|
| 1 | PLAYER_REALPLAY_START | 直播开始 |
| 4 | PLAYER_VOICE_TALK_START | 对讲开始 |
| 5 | PLAYER_VOICE_TALK_END | 对讲结束 |
| 11 | PLAYER_PLAYBACK_START | 录像回放开始 |
| 12 | PLAYER_PLAYBACK_STOP | 录像回放结束 |
| 21 | PLAYER_NET_CHANGED | 网络变更 |

### 多片段连续播放

#### SD 卡录像
将第一个片段的 stopTime 设置为最后一个片段的 stopTime，调用 `startPlaybackFromDevice` 即可连续播放：
```typescript
let playFile = firstRecord.deepCopy()
playFile.stopTime = lastRecord.stopTime
await this.player.startPlaybackFromDevice(playFile)
```

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

#### OSD 定时器
每秒获取 `getOSDTime`，匹配当前片段，更新选中高亮和进度条：
```typescript
let osdTime = await this.player.getOSDTime()
```

## 五、回放控制

### 暂停/恢复
```typescript
await this.player.pausePlayback()
await this.player.resumePlayback()
```

### 进度拖拽（Seek）
```typescript
let secondsAfterStartTime = duringSeconds * (sliderValue / 100)
let offsetTime = new Date(recordFile.startTime.getTime() + secondsAfterStartTime * 1000)
await this.player.seekPlayback(offsetTime)
```

### 倍数回放
```typescript
await this.player.setPlaybackRate(rate, 0)
```

倍速范围：
- SD 卡录像：根据设备能力集动态获取支持的倍速列表，通过 `EZBusinessTool.getSDCardPlaybackRates(deviceInfo, cameraInfo, streamFetchType)` 获取设备实际支持的倍速选项（返回 null 表示不支持倍速），不再写死固定范围
- 云录像：0.5x ~ 32x（固定选项）

SD 卡录像倍速需判断设备能力集：
```typescript
let isSupportPlaybackRate = EZBusinessTool.isSupportPlaybackRate(deviceInfo, cameraInfo)
// 获取设备支持的 SD 卡倍速列表（动态渲染）
let supportedRates = EZBusinessTool.getSDCardPlaybackRates(deviceInfo, cameraInfo, streamFetchType)
// supportedRates 为 null 表示不支持倍速回放，UI 隐藏倍速按钮
// supportedRates 非 null 时 UI 仅展示其中包含的倍速选项
```

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

```typescript
/**
 * 获取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，表示不支持倍数回放
 */
static getSDCardPlaybackRates(deviceInfo: EZDeviceInfo, cameraInfo: EZCameraInfo, streamFetchType: number): EZPlaybackRate[] | null {
  let isSupportRate = EZBusinessTool.isSupportPlaybackRate(deviceInfo, cameraInfo)

  if (isSupportRate) {
    let rates: EZPlaybackRate[] = []

    let supportSmallSpeed = EzvizSupportKit.isSupportPlaybackSmallSpeed(deviceInfo.abilities)
    if (supportSmallSpeed) {
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_1_2)
    }

    let maxSpeed = EzvizSupportKit.getPlaybackMaxSpeed(deviceInfo.abilities)
    if (maxSpeed == 1) {
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_1)
    } else if (maxSpeed == 0) {
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_1)
      if (supportSmallSpeed) {
        rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_2)
      }
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_4)
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_8)
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_16)
    } else {
      rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_1)
      if (supportSmallSpeed) {
        rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_2)
      }
      if (maxSpeed >= 4) {
        rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_4)
      }
      if (maxSpeed >= 8) {
        rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_8)
      }
      if (maxSpeed >= 16) {
        rates.push(EZPlaybackRate.EZOPENSDK_PLAY_RATE_16)
      }
    }
    return rates
  } else {
    let supportDirectInner = EZBusinessTool.isSupportDirectInnerRelaySpeed(deviceInfo, cameraInfo)
    if (streamFetchType == 2 && supportDirectInner) {
      return [EZPlaybackRate.EZOPENSDK_PLAY_RATE_1, EZPlaybackRate.EZOPENSDK_PLAY_RATE_4, EZPlaybackRate.EZOPENSDK_PLAY_RATE_8]
    }
    return null
  }
}
```

## 六、录像下载

### 云存储录像下载
```typescript
const download = new EZCloudStreamDownload()
await download.prepareDownloadParams(savePath, cloudFile)
download.setSecretKey(verifyCode)
download.setStreamDownloadCallback({
  onSuccess: async (filepath) => {
    // 下载成功，保存到相册
    await EZMediaFileUtil.saveMediaToAlbum(filepath, photoAccessHelper.PhotoType.VIDEO)
  },
  onError: (error) => {},
  onErrorCode: (code) => {},
  onDownloadingSize: (downloadSize) => {
    // 下载进度（仅云存储支持）
    let percent = Math.floor(downloadSize * 100 / cloudFile.fileSize)
  }
})
await download.start()
```

### SD 卡录像下载
```typescript
const download = new EZDeviceStreamDownload()
await download.prepareDownloadParams(savePath, deviceSerial, cameraNo, deviceFile)
download.setSecretKey(verifyCode)
// SD卡录像不支持 onDownloadingSize 进度回调
download.setStreamDownloadCallback({
  onSuccess: async (filepath) => {},
  onError: (error) => {},
  onErrorCode: (code) => {}
})
await download.start()
```

**注意**：
- 鸿蒙 SDK 下载的录像文件格式直接为 MP4，无需像 iOS 做 PS→MP4 转码
- 双目设备需设置 `download.setMultiChannelDevice(true)`
- 小权限模式需设置 `download.setStreamToken(streamToken)`

### 云录像封面

`EZCloudRecordFile.coverPic` 是原始封面 URL，**不能直接用**，需要拼接缩略参数和解密密钥：

```typescript
// ⚠️ 必须拼接 &x=宽度 和 &decodekey=验证码（加密设备需要）
let coverUrl = `${record.coverPic}&x=200&decodekey=${verifyCode}`
Image(coverUrl)
```

- `&x=200`：缩略图宽度（像素），控制下载图片大小
- `&decodekey=`：加密设备的验证码，非加密设备传空字符串即可

### SD 卡录像封面

```typescript
// 初始化（aboutToAppear）
EZRecordCoverFetcherManager.getInstance().initFetcher(deviceSerial, cameraNo, {
  onFetcherInitSuccess: () => {},
  onFetcherInitFailed: () => {}
})

// 请求封面（按 UI 展示顺序传入，seq 从 0 开始递增）
for (let i = 0; i < records.length; i++) {
  records[i].seq = i
}
await EZRecordCoverFetcherManager.getInstance().requestRecordCover(records, {
  onGetCoverSuccess: async (seq: number, data: Uint8Array) => {
    // 通过 seq 匹配录像对象，转换为 PixelMap 显示
    let pixelMap = await EZBusinessTool.convertToPixelMap(data)
  },
  onGetCoverFailed: (errorCode: number) => {}
})

// 释放（aboutToDisappear）
EZRecordCoverFetcherManager.getInstance().stopFetcher()
```

## 七、截图

### 播放器截图（取流过程中）
```typescript
let picPath = await this.player?.capturePicture()
// 保存到相册
await EZMediaFileUtil.saveMediaToAlbum(picPath, photoAccessHelper.PhotoType.IMAGE)
```

### 设备抓图（不要求取流）
```typescript
EZOpenSDK.captureCamera(deviceSerial, cameraNo, (url, error) => {
  // url 为图片地址，保留 2 小时
})
```

## 八、录制

### 开始录制
```typescript
let filePath = `${cacheDir}/LocalRecord/${fileName}.mp4`
await fs.open(filePath, fs.OpenMode.CREATE)
await this.player?.startLocalRecordWithPathExt(filePath)
```

### 停止录制
```typescript
await this.player?.stopLocalRecordExt(async (result) => {
  if (result) {
    await EZMediaFileUtil.saveMediaToAlbum(filePath, photoAccessHelper.PhotoType.VIDEO)
    await fs.unlink(filePath)
  }
})
```

**注意**：页面退出时如果正在录制，需在 `onPageHide` 中停止录制。

## 九、清晰度切换

### 获取清晰度列表（取流成功后）
```typescript
this.player?.getDeviceDetailInfo((info) => {
  let qualityInfos = info?.videoQualityInfos // 准确的清晰度列表
  let currentLevel = info?.videoLevel
})
```

### 设置清晰度
```typescript
EZOpenSDK.setVideoLevel(deviceSerial, cameraNo, videoLevelType, async (error) => {
  if (!error) {
    // 设置成功后必须 stopRealPlay → startRealPlay 重新取流
    await this.player?.stopRealPlay()
    await this.player?.startRealPlay()
  }
})
```

**注意**：不要从 `EZCameraInfo.videoQualityInfos` 获取清晰度列表（可能不准确），必须通过 `player.getDeviceDetailInfo` 获取。

## 十、云台控制

### 云台能力判断
```typescript
let supportPTZ = EzvizSupportKit.isSupportPTZ(deviceInfo.abilities)
```

### 云台控制（推荐 controlPTZMix）
```typescript
// 按下：发送 Start
EZOpenSDK.controlPTZMix(deviceSerial, cameraNo, EZPTZCommand.EZPTZCommandLeft, EZPTZAction.EZPTZActionStart, 2, (error) => {})

// 松开：发送 Stop
EZOpenSDK.controlPTZMix(deviceSerial, cameraNo, EZPTZCommand.EZPTZCommandLeft, EZPTZAction.EZPTZActionStop, 2, (error) => {})
```

### 云台角度回调
```typescript
private playerAdditionalInfoCallback: EZPlayerAdditionalInfoCallback = {
  devicePtzAngleInfo: (info: EZDevicePtzAngleInfo) => {
    // info.horizontalCurrentAngle, info.verticalCurrentAngle
  }
}
this.player.setPlayerAdditionalInfoCallback(this.playerAdditionalInfoCallback)
```

## 十一、声音开关

```typescript
// 开启声音
await this.player?.openSound()
// 关闭声音
await this.player?.closeSound()
```

取流成功后声音默认开启。如需静音播放，在 `PLAYER_REALPLAY_START` 回调中关闭：
```typescript
if (messageCode == EZMessageCode.PLAYER_REALPLAY_START) {
  if (!this.isOpenSound) {
    await this.player?.closeSound()
  }
}
```

## 十二、电子放大

```typescript
// 设置电子放大区域（left, top, right, bottom 为像素坐标）
await this.player?.setDisplayRegion(left, top, right, bottom)

// 取消电子放大（传 -1 恢复全画面）
await this.player?.setDisplayRegion(-1, -1, -1, -1)

// 双目设备第二个轨道
await this.player?.setDisplayRegion(left, top, right, bottom, 1)
```

### 手势实现要点

在 XComponent 上绑定 `PinchGesture` 和 `PanGesture`：

```typescript
.gesture(
  GestureGroup(GestureMode.Parallel,
    PinchGesture({ fingers: 2 })
      .onActionStart((event: GestureEvent) => {
        this.baseScale = this.realScale
      })
      .onActionUpdate(async (event: GestureEvent) => {
        let scale = this.baseScale * event.scale
        // ⚠️ 必须用 vp2px 转换坐标
        let centerX = vp2px(event.pinchCenterX)
        let centerY = vp2px(event.pinchCenterY)
        await this.zoomScale(scale, centerX, centerY)
      }),
    PanGesture()
      .onActionStart((event: GestureEvent) => {
        this.lastOffsetX = 0
        this.lastOffsetY = 0
      })
      .onActionUpdate(async (event: GestureEvent) => {
        // ⚠️ 必须用增量（当前 - 上次），再 vp2px 转换
        const deltaX = vp2px(event.offsetX - this.lastOffsetX)
        const deltaY = vp2px(event.offsetY - this.lastOffsetY)
        await this.zoomMove(deltaX, deltaY)
        this.lastOffsetX = event.offsetX
        this.lastOffsetY = event.offsetY
      })
  )
)
```

### 核心逻辑

- `zoomScale`：根据 scale 计算新的显示区域（基于当前 zoomRect 和捏合中心点），scale == 1 时调用 `setDisplayRegion(-1,-1,-1,-1)` 恢复
- `zoomMove`：将手指位移量转换为视频坐标位移量（`-deltaX * zoomWidth / surfaceWidth`），更新 zoomRect 并调用 `setDisplayRegion`
- 边界校正：确保 zoomRect 不超出 `[0, 0, videoWidth, videoHeight]` 范围
- 视频宽高通过 `PLAYER_VIDEO_SIZE_CHANGED` 消息码回调中的 `getVideoWidth()`/`getVideoHeight()` 获取

## 十三、智能分析

```typescript
// 预览成功后调用，在播放过程中随时开关
await this.player?.setIntelAnalysis(true)  // 开启
await this.player?.setIntelAnalysis(false) // 关闭
```

## 十四、门锁开门 Token

```typescript
private playerAdditionalInfoCallback: EZPlayerAdditionalInfoCallback = {
  playPrivateTokenInfo: (tokenInfo: EZPMPlayPrivateTokenInfo) => {
    // tokenInfo.token — 开门 token
  }
}
```

## 十五、告警消息

### 告警列表获取
```typescript
EZOpenSDK.getAlarmList(deviceSerial, pageIndex, pageSize, beginTime, endTime, (alarmList, totalCount, error) => {
})
```

### 告警图片解密
```typescript
await EZOpenSDK.decryptData(alarmInfo.alarmPicUrl, verifyCode, alarmInfo.crypt, (pixelMap, error) => {
})
```

### 事件回放
- 开始时间 = alarmStartTime - preTime
- 结束时间 = alarmStartTime + delayTime
- 用计算出的时间范围查询录像并回放

## 十六、关键方法签名速查

```typescript
// ==================== EZOpenSDK 类方法 ====================
static async initLibWithAppKey(appKey: string, apiUrl?: string, authUrl?: string): Promise<boolean>
static async setAccessToken(accessToken: string): void
static setDebugLogEnable(enable: boolean): boolean
static setDebugStreamEnable(enable: boolean): boolean
static createPlayer(deviceSerial: string, cameraNo: number, useSubStream?: boolean): EZPlayer
static async releasePlayer(player: EZPlayer | null): Promise<boolean>
static setVideoLevel(deviceSerial, cameraNo, videoLevel, callback): void
static controlPTZMix(deviceSerial, cameraNo, command, action, newSpeed, callback): void
static controlPTZEx(deviceSerial, cameraNo, command, action, newSpeed, callback): void

// ==================== EZPlayer 实例方法 ====================
setPlayerView(surfaceId: string, streamId?: number): void
setPlayerCallback(callback: EZPlayerCallback): void
setPlayerAdditionalInfoCallback(callback: EZPlayerAdditionalInfoCallback): void
setPlayVerifyCode(verifyCode: string): void
setSilencePlay(isSilencePlay: boolean): void
async startRealPlay(): Promise<boolean>
async stopRealPlay(): Promise<boolean>
async openSound(): Promise<boolean>
async closeSound(): Promise<boolean>
async startPlaybackFromCloud(cloudFile: EZCloudRecordFile): Promise<boolean>
async startPlaybackFromDevice(deviceFile: EZDeviceRecordFile): Promise<boolean>
async stopPlayback(): Promise<boolean>
async pausePlayback(): Promise<boolean>
async resumePlayback(): Promise<boolean>
async seekPlayback(offsetTime: Date): void
async getOSDTime(): Promise<Date | null>
async setPlaybackRate(rate: EZPlaybackRate, mode: number): Promise<boolean>
async capturePicture(streamId?: number): Promise<string>
async startLocalRecordWithPathExt(path: string): Promise<boolean>
async stopLocalRecordExt(callback: (ret: boolean) => void): void
async getStreamFetchType(): Promise<number>
async getStreamFlow(): Promise<number>
async getVideoWidth(): Promise<number>
async getVideoHeight(): Promise<number>
async setDisplayRegion(left, top, right, bottom, streamId?: number): Promise<boolean>
async setIntelAnalysis(enable: boolean): Promise<boolean>
getDeviceDetailInfo(callback: (info: EZDeviceDetailPublicInfo | null) => void): void
setStreamToken(streamToken: string): void
audioTalkPressed(isPressed: boolean): void
async startVoiceTalk(): Promise<boolean>
async startVoiceTalkNeedVoiceChannel(needVoiceChannel: boolean): Promise<boolean>
async stopVoiceTalk(): Promise<boolean>
setVoiceTalkLoudnessCallback(listener: OnLoudnessListener, interval: number): void

// ==================== EZMessageCode 常量 ====================
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_NET_CHANGED = 21          // 网络变更
PLAYER_VIDEO_SIZE_CHANGED        // 画面尺寸变更
PLAYER_VIDEO_LEVEL_AUTO_IMPROVE = 25  // 网络好，建议升档
PLAYER_VIDEO_LEVEL_AUTO_REDUCE = 26   // 网络差，建议降档

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

## 十七、双目设备支持

双目设备（C7、C60P 等）需要两个 XComponent 分别展示广角和云台镜头画面：

```typescript
// 判断是否双目设备
let isMultiChannel = EzvizSupportKit.isSupportMultiChannel(deviceInfo.abilities)

// 设置第一个播放器视图
this.player?.setPlayerView(surfaceId1)  // streamId 默认为 0
// 设置第二个播放器视图
this.player?.setPlayerView(surfaceId2, 1) // streamId = 1

// 双目设备截图
let pic1 = await this.player?.capturePicture(0)
let pic2 = await this.player?.capturePicture(1)

// 双目设备电子放大
await this.player?.setDisplayRegion(left, top, right, bottom, 0) // 广角
await this.player?.setDisplayRegion(left, top, right, bottom, 1) // 云台

// 双目设备录像下载
download.setMultiChannelDevice(true)
```

## 十八、页面生命周期管理

```typescript
@Entry
@Component
struct EZRealPlayPage {
  async onPageShow() {
    // 页面可见时开始取流（从后台回来也会触发）
    await this.player?.startRealPlay()
  }

  async onPageHide() {
    // 页面不可见时停止取流（进入后台或跳转其他页面）
    // 如果正在录制，先停止录制
    if (this.isRecording) {
      await this.player?.stopLocalRecordExt((result) => {})
    }
    await this.player?.stopRealPlay()
    // 如果正在对讲，停止对讲
    if (this.isTalking) {
      await this.talkPlayer?.stopVoiceTalk()
    }
  }

  async aboutToDisappear() {
    // 页面销毁时释放所有播放器
    await EZOpenSDK.releasePlayer(this.player)
    await EZOpenSDK.releasePlayer(this.talkPlayer)
    // 释放 SD 卡封面管理器（回放页面）
    EZRecordCoverFetcherManager.getInstance().stopFetcher()
  }
}
```
