# OHOS 音视频播放适配指导（Media Kit / Audio Kit）

## 第一部分：什么时候读取这份指导

这份指南用于 Flutter 音频播放 / 视频播放类插件的 HarmonyOS 适配，重点解决 `AVPlayer`、`AudioRenderer`、`PlatformView`、`Texture`、`surfaceId`、`PiP` 这类实现层易错点。

### 适用场景

- 音频播放
- 视频播放/画中画
- 原插件依赖的是系统播放器能力，或依赖第三方播放 SDK，但该 SDK 没有 HarmonyOS 可用实现

如果原插件使用了第三方库实现，优先使用第三方库。

### 选型规则

- **格式化音频**：优先 `Media Kit` 的 `AVPlayer`
- **视频播放**：优先 `Media Kit` 的 `AVPlayer`
- **PCM / 低时延 / 流式写入音频**：优先 `Audio Kit` 的 `AudioRenderer`
- **后台播放 / 锁屏播控 / 耳机键 / 蓝牙播控**：在播放核心之外，再叠加 `AVSession Kit`

---

## 第二部分：Flutter 插件适配方法

### 2.1 先保留原插件公开形态

适配时先看原插件对 Flutter 的公开形态，不要只看底层播放器：

- 原插件对外是“控制器 + 无可视组件”的音频插件：
  - HarmonyOS 侧通常用 `MethodChannel/EventChannel + AVPlayer/AudioRenderer`
  - 不需要 `PlatformView`
- 原插件对外是“视频 Widget / 播放视图”：
  - HarmonyOS 侧必须保留“可嵌入视图”语义
  - 不能退化成单纯 `play(url)` 的无界面调用

### 2.2 视频画面承载怎么选

HarmonyOS 上 `AVPlayer` 负责播放，画面承载还需要一层 Flutter 宿主桥接：

- **优先 `PlatformView + XComponent`**
  - 适合原插件本来就是“平台视图 / 原生视图嵌入”语义
  - 适合需要在原生侧直接组织播放器视图、控制层、手势区域
  - 适合公开能力包含画中画、窗口级小窗、`XComponentController` 或 `SURFACE` 生命周期管理
- **使用 `Texture + surfaceId`**
  - 适合原插件本来就是 texture/controller 架构
  - 适合多个视频实例或渲染层与控制层分离的场景
  - 不适合再额外叠加 HarmonyOS `PiP` 这类强依赖 `XComponentType.SURFACE` 的能力

选择原则：

- 原插件原本是 Widget 视图型，就继续走视图型
- 原插件原本是 Texture 型，就继续走 Texture 型
- 如果原插件公开支持 `PiP`，应优先规划 `PlatformView + XComponentType.SURFACE` 作为主渲染路径
- 不要因为 `Texture` 或 `PlatformView` 某条路更简单，就擅自改变公开 API 和用户可见行为


- 如果 HarmonyOS 侧最终需要 `XComponentController`、`SURFACE`、`typeNode` 或窗口级 `PiP`，说明渲染承载不是“普通 Texture 即可”的场景，规划阶段就要把主方案定到 `PlatformView + XComponent(SURFACE)`
- 若原插件公开的是 `controller + widget` 形态，`textureId` 只应视为实例标识或协议字段，不要把它误判成“必须继续使用 Flutter Texture 后端”

### 2.3 播放核心怎么选

#### 格式化音频 / 格式化视频

优先 `AVPlayer`，因为它直接覆盖：

- `url` / `fdSrc`
- `setMediaSource(mediaSource, playbackStrategy)`
- `prepare / play / pause / stop / reset / release`
- `seek`
- `setVolume`
- `setSpeed`
- 音频场景的 `audioRendererInfo`
- 视频的 `surfaceId` 绑定

这类能力最接近 Flutter 常见播放器插件的公开语义。

#### PCM / 低时延 / 流式音频

优先 `AudioRenderer`，因为这类插件通常不是“给一个 url 自动播”，而是：

- 持续写入 PCM buffer
- 实时输出音频
- 低时延 / 波形 / 音效 / 实时流

这时用 `AVPlayer` 反而不合适。

### 2.4 后台音频是额外层，不是播放核心

- `AVSession Kit` 解决的是后台播放、锁屏控制、播控中心、耳机键等问题
- 它不是播放器本身
- 不要把“接了 AVSession”误认为“已经完成音频播放适配”

### 2.5 视频插件要同时规划“播放器状态机”和“视图挂载顺序”

- `AVPlayer` 的 `url / surfaceId / prepare / play` 有严格状态顺序，不能同步挤在一个片段里调用
- Flutter 侧的视频视图若负责产出 `surfaceId`，native 就不能在 `setDataSource()` 里无限等待 surface，否则容易和 Flutter 的视图创建顺序互锁
- 如果 `initialized / prepared` 依赖 `PlatformView/XComponent` 先挂出来，Flutter 侧不要把视频 Widget 严格门控在 `initialized == true` 之后再创建
- 生成代码时要默认考虑 Flutter rebuild：同一个播放器实例对应的 `PlatformView/XComponent` 可能被重建，不能假设 surface 只会创建一次

### 2.6 播放源配置和 `prepare()` 之间要留出“prepare 前配置窗口”

- 官方文档要求 `stateChange` / `error` 监听要在 `idle`、调用设置资源接口前完成；不要先 `url = ...`，再补监听
- 若原插件支持自定义请求头、HLS / m3u8、播放策略、raw fd、沙箱文件等来源，HarmonyOS 侧不能只写 `player.url = ...`
- 这类场景要根据原插件公开能力，在 `url` / `fdSrc` / `setMediaSource(mediaSource, playbackStrategy)` 中正确选路
- 音频场景若需要设置 `audioRendererInfo`，必须在 `initialized` 且第一次 `prepare()` 之前完成；不要在 `initialized` 后立刻无条件 `prepare()`
- 生成代码时要默认预留“资源已设置，但还没 prepare”的阶段，用于补 `audioRendererInfo`、播放策略、surface 绑定等 prepare 前配置

如果走 `SoundPool`，要记住：`load()` 成功只代表拿到 `soundId`，不代表已经可播放；必须等 `on('loadComplete')` 后再 `play()`，否则容易出现 `has not been loaded completely`。
---

## 第三部分：实现骨架

### 3.1 格式化音频：`AVPlayer`

```typescript
import { media } from '@kit.MediaKit';
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'AudioPlayback';
const DOMAIN = 0xFF00;

export class OhosAudioPlayer {
  private player: media.AVPlayer | null = null;
  private currentState: media.AVPlayerState | null = null;

  async ensurePlayer(): Promise<void> {
    if (this.player !== null) {
      return;
    }
    this.player = await media.createAVPlayer();
    // 必须先监听 stateChange / error，再设置 url / fdSrc / mediaSource
    this.player.on('stateChange', (state: media.AVPlayerState, reason: media.StateChangeReason) => {
      this.currentState = state;
      hilog.info(DOMAIN, TAG, 'state=%{public}s reason=%{public}s', state, `${reason}`);
    });
    this.player.on('error', (error: BusinessError) => {
      hilog.error(DOMAIN, TAG, 'player error: %{public}s', error.message);
    });
  }

  async setSource(url: string): Promise<void> {
    await this.ensurePlayer();
    if (this.player === null) {
      return;
    }

    // 常见场景 1：普通 http/https/file/fd url
    this.player.url = url;

    // 常见场景 2：本地 fd 播放
    // this.player.fdSrc = fdSrc;

    // 常见场景 3：HLS / m3u8 / 自定义请求头 / playbackStrategy
    // this.player.setMediaSource(mediaSource, playbackStrategy);
  }

  async prepare(): Promise<void> {
    if (this.player === null || this.currentState !== 'initialized') {
      return;
    }

    // 如果要设置 audioRendererInfo，只能放在 initialized 且第一次 prepare() 前
    // this.player.audioRendererInfo = {
    //   usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
    //   rendererFlags: 0,
    // };

    await this.player.prepare();
  }

  async play(): Promise<void> {
    if (this.player !== null &&
      (this.currentState === 'prepared' || this.currentState === 'paused' || this.currentState === 'completed')) {
      await this.player.play();
    }
  }

  async pause(): Promise<void> {
    if (this.player !== null && this.currentState === 'playing') {
      await this.player.pause();
    }
  }

  async seek(positionMs: number): Promise<void> {
    if (this.player !== null &&
      (this.currentState === 'prepared' || this.currentState === 'playing' || this.currentState === 'paused' || this.currentState === 'completed')) {
      await this.player.seek(positionMs);
    }
  }

  async release(): Promise<void> {
    if (this.player !== null) {
      await this.player.release();
      this.player = null;
      this.currentState = null;
    }
  }
}
```

关键点：

- `url` / `fdSrc` 赋值后不要立刻假设已经可播，等 `stateChange -> initialized`
- `prepare()` 完成后再进入 `play()`
- **重要**：`setLooping / setVolume / setSpeed / seek / play` 若早于允许状态到达，应先缓存，等 `prepared` 后再应用
- 这层最适合对接 Flutter 侧控制器 API
- 不要把 `setLooping(false)`、`setVolume(1.0)` 这类 create 后立即下发的控制调用直接打到未 ready 的 `AVPlayer`

### 3.2 PCM / 流式音频：`AudioRenderer`

```typescript
import { audio } from '@kit.AudioKit';

export class OhosPcmRenderer {
  private renderer: audio.AudioRenderer | null = null;

  async ensureRenderer(options: audio.AudioRendererOptions): Promise<void> {
    if (this.renderer !== null) {
      return;
    }
    this.renderer = await audio.createAudioRenderer(options);
  }

  async start(): Promise<void> {
    await this.renderer?.start();
  }

  async write(buffer: ArrayBuffer): Promise<number> {
    if (this.renderer === null) {
      return 0;
    }
    return await this.renderer.write(buffer);
  }

  async stop(): Promise<void> {
    await this.renderer?.stop();
  }

  async release(): Promise<void> {
    await this.renderer?.release();
    this.renderer = null;
  }
}
```

关键点：

- 这层适合 `PCM`、实时流、低时延音频插件
- 若原插件公开的是“持续喂数据”，不要强改成 `setUrl()`

### 3.3 视频在 Flutter 上展示：`OhosView + PlatformView + XComponent + AVPlayer`

如果原插件对外是视频 Widget，HarmonyOS 侧更稳的落地方式是：

1. Dart 侧保留原插件的视频 Widget 语义，在 OHOS 分支使用 `OhosView`
2. ETS 端注册 `PlatformViewFactory`
3. `PlatformView` 内部创建 `XComponentType.SURFACE`
4. `XComponent.onLoad()` 时把 `surfaceId` 回绑给播放器实例
5. `AVPlayer` 等到 `initialized + surfaceReady` 后再 `prepare()`

#### Dart 侧承载骨架

```dart
@override
Widget buildView(int textureId) {
  if (Platform.isOhos) {
    return OhosView(
      viewType: 'ohos_video_player_view',
      creationParamsCodec: const StandardMessageCodec(),
      creationParams: {'textureId': textureId},
    );
  }
  return Texture(textureId: textureId);
}
```

#### ETS 端 PlatformView 骨架

```typescript
import {
  Any,
  PlatformView,
  PlatformViewFactory,
  StandardMessageCodec,
} from '@ohos/flutter_ohos';
import { Params } from '@ohos/flutter_ohos/src/main/ets/plugin/platform/PlatformView';
import common from '@ohos.app.ability.common';

export interface SurfaceReadyCallback {
  onSurfaceReady(playerId: number, surfaceId: string, controller: XComponentController): void;
  onSurfaceDestroyed(playerId: number, surfaceId: string): void;
}

@Builder
function buildVideoSurface(params: Params) {
  VideoSurfaceComponent({ platformView: params.platformView as OhosVideoPlatformView });
}

@Component
struct VideoSurfaceComponent {
  platformView: OhosVideoPlatformView | null = null;

  build() {
    if (this.platformView !== null) {
      XComponent(this.platformView.getXComponentOptions())
        .onLoad(() => {
          this.platformView?.onLoad();
        })
        .onDestroy(() => {
          this.platformView?.onDestroy();
        })
        .size({ width: '100%', height: '100%' });
    }
  }
}

export class OhosVideoPlatformView extends PlatformView {
  private readonly playerId: number;
  private readonly callback: SurfaceReadyCallback;
  private readonly controller: XComponentController = new XComponentController();
  private readonly options: XComponentOptions;
  private surfaceId: string = '';

  constructor(playerId: number, callback: SurfaceReadyCallback) {
    super();
    this.playerId = playerId;
    this.callback = callback;
    this.options = {
      type: XComponentType.SURFACE,
      controller: this.controller,
    };
  }

  getView(): WrappedBuilder<[Params]> {
    return wrapBuilder<[Params]>(buildVideoSurface);
  }

  getXComponentOptions(): XComponentOptions {
    return this.options;
  }

  onLoad(): void {
    this.surfaceId = this.controller.getXComponentSurfaceId();
    this.callback.onSurfaceReady(this.playerId, this.surfaceId, this.controller);
  }

  onDestroy(): void {
    this.callback.onSurfaceDestroyed(this.playerId, this.surfaceId);
  }
}

export class OhosVideoViewFactory extends PlatformViewFactory {
  private readonly callback: SurfaceReadyCallback;

  constructor(callback: SurfaceReadyCallback) {
    super(new StandardMessageCodec());
    this.callback = callback;
  }

  create(context: common.Context, viewId: number, args: Any): PlatformView {
    const creationParams = args as Map<string, Object>;
    const playerId = creationParams.get('textureId') as number;
    return new OhosVideoPlatformView(playerId, this.callback);
  }
}
```

#### 播放器实例骨架

```typescript
import { media } from '@kit.MediaKit';

export class VideoPlayerEntry {
  private player: media.AVPlayer;
  private currentState: media.AVPlayerState = 'idle';
  private primarySurfaceId: string = '';
  private currentUri: string = '';
  private pendingPlay: boolean = false;
  private pendingVolume: number = 1.0;

  constructor(player: media.AVPlayer) {
    this.player = player;
    this.player.on('stateChange', (state: media.AVPlayerState) => {
      this.currentState = state;
      if (state === 'prepared') {
        this.player.setVolume(this.pendingVolume);
        if (this.pendingPlay) {
          void this.player.play();
          this.pendingPlay = false;
        }
      }
    });
  }

  attachPrimarySurface(surfaceId: string): void {
    this.primarySurfaceId = surfaceId;
    if (this.currentState === 'initialized' && this.currentUri.length > 0) {
      void this.prepareIfPossible();
    }
  }

  async setDataSource(url: string): Promise<void> {
    this.currentUri = url;
    this.player.url = url;
    // wait until initialized, then wait until surface is ready
  }

  async prepareIfPossible(): Promise<void> {
    if (this.currentState !== 'initialized' || this.primarySurfaceId === '') {
      return;
    }
    this.player.surfaceId = this.primarySurfaceId;
    await this.player.prepare();
  }

  setVolume(volume: number): void {
    this.pendingVolume = volume;
    if (this.currentState === 'prepared' || this.currentState === 'playing' || this.currentState === 'paused') {
      this.player.setVolume(volume);
    }
  }

  async play(): Promise<void> {
    if (this.currentState === 'prepared' || this.currentState === 'paused' || this.currentState === 'completed') {
      await this.player.play();
      return;
    }
    this.pendingPlay = true;
  }
}
```

#### 插件入口注册

```typescript
binding.getPlatformViewRegistry()
  .registerViewFactory('ohos_video_player_view', new OhosVideoViewFactory(this));
```

关键点：

- `viewType` 必须和 Dart 侧逐字一致
- `surfaceId` 来自 `XComponentController.getXComponentSurfaceId()`
- 先 `url -> initialized`，再等 `surfaceReady`，最后 `prepare()`
- `setLooping / setVolume / setSpeed / play / seek` 在未 ready 时要先缓存，等 `prepared` 后再应用
- `XComponent` rebuild 或重建后，新的 `surfaceId` 必须重新绑定到同一个 `AVPlayer`
- `surfaceDestroyed` 时只能清理被销毁的那块 surface，不要把当前正在使用的新 surface 一并清掉
- 释放时一定 `player.release()`
- 如果 Dart 侧 `EventChannel` 是按实例名分流，如 `videoEvents{textureId}`，native 必须在 `create()` 时同步创建并注册对应通道，不能只建一个全局事件通道

### 3.4 如果原插件本来是 Texture 架构

很多视频插件原本就是 `Texture` 控制器模式，这时应沿用原语义：

- Dart 侧继续用 `Texture(textureId: ...)`
- ETS 端用 `TextureRegistry` 注册纹理，拿到 `textureId` 和 `surfaceId`
- `AVPlayer` 仍绑定 `surfaceId`
- 事件通道、方法参数、`textureId` 传递方式必须和 Dart 侧逐字一致，尤其是 `videoEvents{textureId}` 这类按实例分流的通道
- 若后续还要支持 HarmonyOS `PiP`，不要先选 `Texture` 再指望从 Texture 路径直接拿到 PiP 所需的 `XComponentController`

#### Dart 侧承载骨架

```dart
@override
Widget buildView(int textureId) {
  return Texture(textureId: textureId);
}
```

#### ETS 端 Texture 注册骨架

```typescript
import {
  BinaryMessenger,
  EventChannel,
  EventSink,
  FlutterPluginBinding,
  TextureRegistry,
} from '@ohos/flutter_ohos';
import { media } from '@kit.MediaKit';

export class OhosTextureVideoPlugin {
  private textureRegistry: TextureRegistry;
  private binaryMessenger: BinaryMessenger;
  private playerEntries: Map<number, VideoPlayerEntry> = new Map();
  private eventSinks: Map<number, EventSink> = new Map();

  constructor(binding: FlutterPluginBinding) {
    this.textureRegistry = binding.getTextureRegistry();
    this.binaryMessenger = binding.getBinaryMessenger();
  }

  async createPlayer(): Promise<Map<string, Object>> {
    const textureId = this.textureRegistry.getTextureId();
    const textureEntry = this.textureRegistry.registerTexture(textureId);
    const surfaceId = textureEntry.getSurfaceId().toString();

    const eventChannel = new EventChannel(
      this.binaryMessenger,
      'videoEvents' + textureId.toString()
    );
    eventChannel.setStreamHandler({
      onListen: (args: Object, events: EventSink): void => {
        this.eventSinks.set(textureId, events);
      },
      onCancel: (): void => {
        this.eventSinks.delete(textureId);
      },
    });

    const player = await media.createAVPlayer();
    const entry = new VideoPlayerEntry(textureId, player, surfaceId, this.eventSinks, textureEntry);
    this.playerEntries.set(textureId, entry);

    const result = new Map<string, Object>();
    result.set('textureId', textureId);
    return result;
  }
}
```

#### 播放器实例骨架

```typescript
import { EventSink, SurfaceTextureEntry, TextureRegistry } from '@ohos/flutter_ohos';
import { media } from '@kit.MediaKit';

export class VideoPlayerEntry {
  private readonly textureId: number;
  private readonly player: media.AVPlayer;
  private readonly surfaceId: string;
  private readonly eventSinks: Map<number, EventSink>;
  private readonly textureEntry: SurfaceTextureEntry;
  private currentState: media.AVPlayerState = 'idle';
  private pendingPlay: boolean = false;
  private pendingVolume: number = 1.0;

  constructor(
    textureId: number,
    player: media.AVPlayer,
    surfaceId: string,
    eventSinks: Map<number, EventSink>,
    textureEntry: SurfaceTextureEntry
  ) {
    this.textureId = textureId;
    this.player = player;
    this.surfaceId = surfaceId;
    this.eventSinks = eventSinks;
    this.textureEntry = textureEntry;

    this.player.on('stateChange', async (state: media.AVPlayerState) => {
      this.currentState = state;
      if (state === 'initialized') {
        this.player.surfaceId = this.surfaceId;
        await this.player.prepare();
      } else if (state === 'prepared') {
        this.player.setVolume(this.pendingVolume);
        if (this.pendingPlay) {
          await this.player.play();
          this.pendingPlay = false;
        }
        const event = new Map<string, Object>();
        event.set('event', 'initialized');
        event.set('duration', this.player.duration);
        event.set('width', this.player.width);
        event.set('height', this.player.height);
        this.eventSinks.get(this.textureId)?.success(event);
      }
    });
  }

  async setDataSource(url: string): Promise<void> {
    this.player.url = url;
  }

  async play(): Promise<void> {
    if (this.currentState === 'prepared' || this.currentState === 'paused' || this.currentState === 'completed') {
      await this.player.play();
      return;
    }
    this.pendingPlay = true;
  }

  setVolume(volume: number): void {
    this.pendingVolume = volume;
    if (this.currentState === 'prepared' || this.currentState === 'playing' || this.currentState === 'paused') {
      this.player.setVolume(volume);
    }
  }

  async release(textureRegistry: TextureRegistry): Promise<void> {
    await this.player.release();
    textureRegistry.unregisterTexture(this.textureId);
  }
}
```

关键点：

- OHOS `TextureRegistry` 路径优先使用 `getTextureId() + registerTexture(textureId)`；不要把废弃的 `createSurfaceTexture()` 当成主骨架
- `textureId` 更多是 Flutter 协议层的播放器实例标识，真实出画目标仍是 `surfaceId`；不要把两者混成同一个概念
- `surfaceId` 注册出来后应尽早绑定到 `AVPlayer`，但 `prepare()` 仍要等 `initialized`
- Dart 侧 `receiveBroadcastStream(...)` 若传了实例参数，native `onListen(args, events)` 必须逐字接住；若没传，也要保证每实例通道名逐字一致
- `dispose()` 时要释放 `AVPlayer`，并从 `TextureRegistry` 注销纹理；不要把 `SurfaceTextureEntry.release()` 当成主清理路径
- 如果 Flutter 侧会销毁重建同一个 Texture 视图，要重新检查 `textureId -> surfaceId` 是否仍有效

不要把原来的 Texture 插件强改成新的公开 Widget API。

---

## 第四部分：Flutter 适配注意事项

### 4.1 不要新造公开 API
- 要保持原插件已有的 controller、stream、callback、widget 语义

### 4.2 视频插件要保留“视图型”语义

- 如果原插件是 `VideoPlayerController + VideoPlayer(widget)`，HarmonyOS 侧也要能嵌入视频画面
- 不能偷懒改成“只返回播放成功”

### 4.3 音频插件优先保留“控制器型”语义

- 音频插件一般不需要 `PlatformView`
- 重点是播控、状态回调、进度、buffer、速度、循环、后台播放

### 4.4 后台播放是组合能力

- 音频后台播放：`AVPlayer/AudioRenderer` + `AVSession`
- 视频前台播放：通常只需 `AVPlayer`
- 视频后台继续播/播控中心/锁屏控制：再加 `AVSession`

### 4.5 不要把普通播放和 PiP 做成两套互相打架的主链路

- 若 HarmonyOS 侧必须同时支持普通播放和 `PiP`，优先先定一条主渲染路径，再规划 surface 切换
- 不要先让普通播放走一套、`PiP` 再临时补另一套完全独立的播放器链路
- 更推荐“一套 `AVPlayer` + 两个阶段的 surface 绑定”，而不是“两套播放器内核”

### 4.6 视频类插件生成时的最低自检项

- Dart 侧视频 Widget 是否会在播放器未 `initialized` 时就先挂出承载视图；若不会，需警惕与 native 等 surface 的互锁
- OHOS 侧是否实现了 `AbilityAware`；若涉及 `PiP`、窗口、`UIContext`、主窗口获取，通常不能只靠 `ApplicationContext`
- 每实例事件通道是否真正注册，Dart `receiveBroadcastStream(...)` 是否把实例参数逐字传到 native
- `surfaceCreated / surfaceDestroyed / rebuild / dispose` 是否都接到了播放器重绑或清理逻辑

### 4.7 URL源播放音视频注意
如果原 Example 中使用了 googleapis.com 的测试地址，由于国内网络原因，一定播放失败。请修改为如下测试地址：
视频：https://media.w3.org/2010/05/sintel/trailer.mp4
音频：http://music.163.com/song/media/outer/url?id=447925558.mp3

## 补充说明

- 更多 API 用法需要查询官方文档。
