Flutter for OpenHarmony 跨平台工程集成音视频播放能力实战

作者:maaath


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net


一、前言

在移动应用开发中,音视频播放能力几乎是刚需。无论是社交应用中的背景音乐、短视频播放,还是工具类应用内的多媒体展示,都离不开这一功能。而在 OpenHarmony 生态中,如何在 Flutter 跨平台工程中优雅地集成原生音视频能力,始终是开发者社区热议的话题。

本文将以一个完整的工程实践为例,详细讲解如何在 Flutter for OpenHarmony 跨平台工程中,利用 ArkTS 原生能力实现:音频播放(背景音乐与音效)、视频播放(横竖屏切换与进度控制)以及播放列表管理。本文所附代码均提取自真实项目,已在鸿蒙设备上验证可运行。


二、技术方案选型

当前在 OpenHarmony 上实现音视频播放,主流方案有两条路:

方案一:纯 ArkTS 实现。 直接使用 MediaKit 中的 AVPlayer,完全在 Native 侧实现,Flutter 仅负责嵌入展示。优点是性能最优、与系统媒体服务深度集成;缺点是 Flutter 端需要通过 Platform Channel 与原生通信。

方案二:Flutter 插件封装。 通过 video_playeraudioplayers 等 Flutter 插件,在插件内部完成对 AVPlayer 的封装。优点是接口风格统一、Flutter 开发者上手快;缺点是插件生态尚不成熟,部分插件还未完成鸿蒙化适配。

综合稳定性与可维护性考量,本文采用方案一:以 ArkTS 为核心实现音视频播放逻辑,通过 @ohos/flutter_ohos 将 Flutter 页面嵌入到原生 Ability 中,实现跨平台工程的混合渲染。以下是整体架构图:

┌─────────────────────────────────────────────┐
│              Flutter Engine                   │
│  (Flutter UI: 列表页、详情页、Flutter 组件)    │
└──────────────┬──────────────────────────────┘
               │ Platform Channel
┌──────────────▼──────────────────────────────┐
│        FlutterAbility (ArkTS)               │
│   ├── FlutterEngine 配置与初始化             │
│   └── GeneratedPluginRegistrant             │
└──────────────┬──────────────────────────────┘
               │ LocalStorage / EventHub
┌──────────────▼──────────────────────────────┐
│     MediaPlayerPage (ArkTS Native UI)        │
│  ├── UI 组件层:Tab 切换、播放器控件          │
│  └── 播放列表管理:添加、删除、随机、循环      │
└──────────────┬──────────────────────────────┘
               │
┌──────────────▼──────────────────────────────┐
│      MediaPlayerService (业务逻辑层)         │
│  ├── AVPlayer 生命周期管理                   │
│  ├── 播放状态订阅与分发                      │
│  └── 播放列表数据模型                        │
└──────────────┬──────────────────────────────┘
               │
┌──────────────▼──────────────────────────────┐
│           MediaKit (AVPlayer)               │
│  ├── 音频播放:avAudioPlayer               │
│  └── 视频播放:player (Surface)            │
└─────────────────────────────────────────────┘

三、数据模型设计

良好的数据模型是工程可维护性的基础。本项目定义了三层数据结构:

3.1 媒体类型与播放状态枚举

// entry/src/main/ets/model/AudioVideoModels.ets

export enum MediaType {
  AUDIO = 0,
  VIDEO = 1
}

export enum PlaybackState {
  IDLE = 'idle',
  LOADING = 'loading',
  PLAYING = 'playing',
  PAUSED = 'paused',
  STOPPED = 'stopped',
  COMPLETED = 'completed',
  ERROR = 'error'
}

export enum RepeatMode {
  NONE = 0,   // 不循环
  ONE = 1,    // 单曲循环
  ALL = 2     // 列表循环
}

3.2 媒体条目与播放信息

export interface MediaItem {
  id: string;
  title: string;
  artist: string;
  album?: string;
  duration: number;      // 秒
  url: string;
  coverUrl?: string;
  mediaType: MediaType;
  size?: number;         // 字节
}

export interface PlaybackInfo {
  state: PlaybackState;
  currentItem: MediaItem | null;
  currentTime: number;   // 秒
  duration: number;      // 秒
  buffered: number;      // 百分比 0-100
  volume: number;         // 0.0 - 1.0
  playbackRate: number;  // 倍速
  isMuted: boolean;
  isLooping: boolean;
}

3.3 播放列表类

export class Playlist {
  id: string;
  name: string;
  items: MediaItem[] = [];
  currentIndex: number = 0;
  isShuffled: boolean = false;
  repeatMode: RepeatMode = RepeatMode.NONE;
  private originalOrder: MediaItem[] = [];

  moveToNext(): boolean { /* 切换下一首 */ }
  moveToPrevious(): boolean { /* 切换上一首 */ }
  addItem(item: MediaItem): void { /* 添加条目 */ }
  removeItem(index: number): MediaItem | null { /* 移除条目 */ }
  clear(): void { /* 清空列表 */ }
  shuffle(): void { /* Fisher-Yates 洗牌算法 */ }
  resetOrder(): void { /* 恢复原始顺序 */ }
}

其中 shuffle() 方法采用了经典的 Fisher-Yates 洗牌算法,确保每次打乱都是等概率的:

shuffle(): void {
  for (let i = this.items.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    const temp = this.items[i];
    this.items[i] = this.items[j];
    this.items[j] = temp;
  }
  this.isShuffled = true;
}

四、服务层实现:MediaPlayerService

服务层是整个音视频能力的核心。它负责 AVPlayer 的创建与销毁、事件的订阅与分发、以及播放列表的状态管理。

4.1 初始化与播放器创建

// entry/src/main/ets/service/MediaPlayerService.ets

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

export class MediaPlayerService {
  private player: media.AVPlayer | null = null;
  private avAudioPlayer: media.AVPlayer | null = null;
  private audioPlaylist: Playlist = new Playlist('audio_playlist', '我的音乐');
  private videoPlaylist: Playlist = new Playlist('video_playlist', '我的视频');
  private isAudioPlayerMode: boolean = true;
  private onPlaybackInfoUpdate: ((info: PlaybackInfo) => void) | null = null;
  private onError: ((error: string) => void) | null = null;

  async initialize(type: MediaType): Promise<void> {
    this.stopProgressTimer();
    this.release();

    this.isAudioPlayerMode = (type === MediaType.AUDIO);
    const player = this.isAudioPlayerMode
      ? await media.createAVPlayer()
      : await media.createAVPlayer();

    if (this.isAudioPlayerMode) {
      this.avAudioPlayer = player;
    } else {
      this.player = player;
    }

    this.setupEventListeners(player);
    this.startProgressTimer();
  }

  private setupEventListeners(player: media.AVPlayer): void {
    player.on('stateChange', (state: string) => {
      this.handleStateChange(state);
    });

    player.on('bufferingUpdate', (infoType: media.BufferingInfoType, value: number) => {
      if (infoType === media.BufferingInfoType.BUFFERING_PERCENT) {
        this.notifyUpdate({ buffered: value });
      }
    });

    player.on('speedDone', (speed: number) => {
      this.notifyUpdate({ playbackRate: speed });
    });

    player.on('error', () => {
      this.notifyUpdate({ state: PlaybackState.ERROR });
      if (this.onError) {
        this.onError(`播放错误: ${player.state}`);
      }
    });

    player.on('volumeChange', () => {
      this.notifyUpdate({ volume: player.volume });
    });
  }
}

4.2 播放控制核心方法

async loadAndPlay(mediaItem: MediaItem): Promise<void> {
  this.notifyUpdate({ state: PlaybackState.LOADING });

  const player = this.getCurrentPlayer();
  const playlist = this.getCurrentPlaylist();

  player.reset();

  const avInfo: media.AVFileDescriptor = {
    fd: globalThis.resourceManager.getRawFileDescriptorSync(mediaItem.url)
  };
  player.url = mediaItem.url;

  await new Promise<void>((resolve) => {
    player.on('stateChange', (state: string) => {
      if (state === 'idle') {
        player.url = mediaItem.url;
      }
      if (state === 'prepared') {
        this.notifyUpdate({
          state: PlaybackState.PLAYING,
          currentItem: mediaItem,
          duration: player.duration / 1000
        });
        player.play();
        resolve();
      }
    });
  });
}

async play(): Promise<void> {
  const player = this.getCurrentPlayer();
  if (player) {
    await player.play();
    this.notifyUpdate({ state: PlaybackState.PLAYING });
  }
}

async pause(): Promise<void> {
  const player = this.getCurrentPlayer();
  if (player) {
    await player.pause();
    this.notifyUpdate({ state: PlaybackState.PAUSED });
  }
}

async stop(): Promise<void> {
  const player = this.getCurrentPlayer();
  if (player) {
    await player.stop();
    this.notifyUpdate({ state: PlaybackState.STOPPED, currentTime: 0 });
  }
}

async seekTo(seconds: number): Promise<void> {
  const player = this.getCurrentPlayer();
  if (player) {
    try {
      await player.seek(seconds * 1000); // AVPlayer.seek 单位为毫秒
    } catch (err) {
      console.error(`[MediaPlayer] Seek failed: ${(err as BusinessError).message}`);
    }
  }
}

async seekForward(seconds: number = 10): Promise<void> {
  const info = this.getCurrentPlaybackInfo();
  const newTime = Math.min(info.currentTime + seconds, info.duration);
  await this.seekTo(newTime);
}

async seekBackward(seconds: number = 10): Promise<void> {
  const info = this.getCurrentPlaybackInfo();
  const newTime = Math.max(info.currentTime - seconds, 0);
  await this.seekTo(newTime);
}

4.3 播放列表管理

next(): void {
  const playlist = this.getCurrentPlaylist();
  if (playlist.moveToNext()) {
    const nextItem = playlist.items[playlist.currentIndex];
    this.loadAndPlay(nextItem);
  } else if (playlist.repeatMode === RepeatMode.ALL) {
    playlist.currentIndex = 0;
    this.loadAndPlay(playlist.items[0]);
  }
}

previous(): void {
  const playlist = this.getCurrentPlaylist();
  const info = this.getCurrentPlaybackInfo();
  if (info.currentTime > 3) {
    this.seekTo(0);
  } else {
    playlist.moveToPrevious();
    this.loadAndPlay(playlist.items[playlist.currentIndex]);
  }
}

toggleRepeatMode(): void {
  const playlist = this.getCurrentPlaylist();
  const modes = [RepeatMode.NONE, RepeatMode.ALL, RepeatMode.ONE];
  const nextIndex = (modes.indexOf(playlist.repeatMode) + 1) % modes.length;
  playlist.repeatMode = modes[nextIndex];
}

toggleShuffle(): void {
  const playlist = this.getCurrentPlaylist();
  if (playlist.isShuffled) {
    playlist.resetOrder();
  } else {
    playlist.shuffle();
  }
}

4.4 进度定时器

为了实时更新播放进度与当前时间,服务层维护了一个定时器:

private progressTimer: number = -1;

private startProgressTimer(): void {
  this.stopProgressTimer();
  this.progressTimer = setInterval(() => {
    const player = this.getCurrentPlayer();
    if (player) {
      this.notifyUpdate({
        currentTime: player.currentTime / 1000
      });
    }
  }, 500); // 每 500ms 更新一次进度
}

private stopProgressTimer(): void {
  if (this.progressTimer !== -1) {
    clearInterval(this.progressTimer);
    this.progressTimer = -1;
  }
}

⚠️ 注意事项: AVPlayer.currentTime 的单位为毫秒,而 MediaItem.duration 的单位为,在计算进度百分比时需注意单位统一,否则会导致进度条显示异常。


五、UI 层实现:MediaPlayerPage

UI 层采用 Tab 分栏 布局,分为「音频」「视频」「播放列表」三个页面,通过 @State 状态变量控制当前激活的 Tab。

5.1 页面状态与生命周期

// entry/src/main/ets/pages/MediaPlayerPage.ets

@Entry
@Component
export struct MediaPlayerPage {
  @State activeTab: number = 0;
  @State playbackInfo: PlaybackInfo = this.getDefaultPlaybackInfo();
  @State isVideoFullscreen: boolean = false;
  @State showVolumeSlider: boolean = false;
  @State showSpeedPicker: boolean = false;
  @State showPlaylistSheet: boolean = false;
  @State sliderDragging: boolean = false;

  private mediaPlayerService: MediaPlayerService | null = null;

  aboutToAppear(): void {
    // 全屏模式下强制横屏
    if (this.isVideoFullscreen) {
      display.getDefaultDisplaySync().setOrientation(0); // LANDSCAPE
    }

    this.mediaPlayerService = new MediaPlayerService();
    this.mediaPlayerService.setPlaybackInfoListener((info) => {
      this.playbackInfo = info;
    });
    this.mediaPlayerService.setErrorListener((error) => {
      promptAction.showToast({ message: error, duration: 3000 });
    });

    this.mediaPlayerService.initialize(MediaType.AUDIO);
  }

  aboutToDisappear(): void {
    if (this.isVideoFullscreen) {
      display.getDefaultDisplaySync().setOrientation(1); // PORTRAIT
    }
    this.mediaPlayerService?.release();
  }
}

5.2 进度条与时间显示

进度条是播放器最重要的交互组件之一。本实现采用 Slider 组件结合自定义圆点,并监听触摸事件实现精确跳转:

@Builder
buildProgressSlider() {
  Row() {
    Text(this.formatTime(
      this.sliderDragging ? this.dragPosition : this.playbackInfo.currentTime
    ))
      .fontSize(12)
      .fontColor('#B0B0B0')
      .width(45)

    Slider({
      value: this.getProgressPercent(),
      min: 0,
      max: 100,
      style: SliderStyle.OutSet,
      blockColor('#2196F3'),
      trackColor('#505050', '#2196F3'),
      showTips: false
    })
    .layoutWeight(1)
    .onTouch((event) => {
      if (event.type === TouchType.Down) {
        this.sliderDragging = true;
      } else if (event.type === TouchType.Up && this.sliderDragging) {
        // 使用 touches[0].x 获取触摸横坐标计算进度
        const touchX = event.touches[0].x;
        const percent = Math.max(0, Math.min(100, (touchX / 320) * 100));
        const seconds = Math.floor((percent / 100) * this.playbackInfo.duration);
        this.mediaPlayerService?.seekTo(seconds);
        this.sliderDragging = false;
      }
    })

    Text(this.formatTime(this.playbackInfo.duration))
      .fontSize(12)
      .fontColor('#B0B0B0')
      .width(45)
      .textAlign(TextAlign.End)
  }
  .width('100%')
}

⚠️ 关键点: 在 ArkUI 中,手指触摸事件的横坐标应通过 event.touches[0].x 获取,而非直接使用 event.x。这是因为 touches 数组包含了所有触控点的信息,在多点触控场景下能准确定位每个触点。

5.3 播放控制栏

@Builder
buildAudioControls() {
  Column({ space: 16 }) {
    Row({ space: 32 }) {
      // 随机播放
      Text('🔀')
        .fontSize(24)
        .opacity(this.playbackInfo.isLooping ? 1 : 0.5)
        .onClick(() => this.mediaPlayerService?.toggleShuffle())

      // 快退 10s
      Text('⏪')
        .fontSize(28)
        .onClick(() => this.mediaPlayerService?.seekBackward(10))

      // 上一首
      Text('⏮')
        .fontSize(28)
        .onClick(() => this.mediaPlayerService?.previous())

      // 播放/暂停(核心按钮,面积更大)
      Text(this.playbackInfo.state === PlaybackState.PLAYING ? '⏸' : '▶')
        .fontSize(40)
        .fontColor('#FFFFFF')
        .backgroundColor('#2196F3')
        .borderRadius(40)
        .padding(16)
        .onClick(() => this.togglePlayPause())

      // 下一首
      Text('⏭')
        .fontSize(28)
        .onClick(() => this.mediaPlayerService?.next())

      // 快进 10s
      Text('⏩')
        .fontSize(28)
        .onClick(() => this.mediaPlayerService?.seekForward(10))

      // 循环模式
      Text(this.getRepeatIcon())
        .fontSize(24)
        .opacity(this.playbackInfo.isLooping ? 1 : 0.5)
        .onClick(() => this.mediaPlayerService?.toggleRepeatMode())
    }
    .justifyContent(FlexAlign.Center)
    .width('100%')
  }
}

5.4 视频全屏播放

视频模式的核心是全屏切换功能。当用户点击全屏按钮时,需要切换屏幕方向并重新布局控件:

@Builder
buildVideoFullscreenView() {
  Stack() {
    Video()
      .width('100%')
      .height('100%')
      .autoPlay(true)
      .controls(false)

    // 全屏控制栏(底部渐变遮罩)
    Column() {
      Blank()
      Row() {
        Text('⏮').fontSize(28).onClick(() => this.mediaPlayerService?.previous())
        Text(this.playbackInfo.state === PlaybackState.PLAYING ? '⏸' : '▶')
          .fontSize(36).onClick(() => this.togglePlayPause())
        Text('⏭').fontSize(28).onClick(() => this.mediaPlayerService?.next())
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding({ bottom: 24 })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#00000000', 0], ['#CC000000', 1]]
    })
  }
  .width('100%')
  .height('100%')
  .gesture(
    Tap().onAction(() => {
      animateTo({ duration: 200 }, () => {
        this.showVideoControls = !this.showVideoControls;
      });
    })
  )
}

5.5 时间格式化工具

formatDuration(seconds: number): string {
  if (isNaN(seconds) || seconds < 0) return '00:00';
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) {
    return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
  }
  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}

六、Flutter 跨平台集成

本项目最有特色的地方在于:主页 (Index.ets) 并非纯原生开发,而是通过 @ohos/flutter_ohos 嵌入了 Flutter 引擎,实现 Flutter 与 ArkTS 的混合渲染。

6.1 Ability 中配置 Flutter 引擎

// entry/src/main/ets/entryability/EntryAbility.ets

import { FlutterAbility } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine);
    GeneratedPluginRegistrant.registerWith(flutterEngine);
  }
}

6.2 原生页面中嵌入 Flutter 视图

// entry/src/main/ets/pages/Index.ets

import { FlutterPage } from '@ohos/flutter_ohos';

@Entry(storage)
@Component
struct Index {
  @LocalStorageLink('viewId') viewId: string = "";

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      // Flutter 页面作为底层背景
      Column() {
        FlutterPage({ viewId: this.viewId })
          .opacity(this.flutterOpacity)
      }
      .width('100%')
      .height('100%')

      // 原生 UI 覆盖层:导航按钮 + 媒体播放器卡片
      this.buildMediaPlayerCard()

      Column({ space: 12 }) {
        // 导航按钮...
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      .backgroundColor('#F5F7FA')
    }
    .width('100%')
    .height('100%')
  }
}

这种架构的巧妙之处在于:Flutter 负责复杂的列表渲染和页面导航(这些是 Flutter 的强项),而 ArkTS 负责音视频播放、传感器等需要深度系统集成的功能。两者各司其职,互补长短。


七、播放列表初始化数据

为了方便读者验证,这里列出项目中使用的演示数据:

// 演示音频数据
const demoAudioTracks: MediaItem[] = [
  {
    id: 'audio_001',
    title: '晨曦轻语',
    artist: '开源 Harmony',
    album: '开源之声 Vol.1',
    duration: 245,
    url: 'resources/rawfile/audio/track001.mp3',
    mediaType: MediaType.AUDIO
  },
  {
    id: 'audio_002',
    title: '暮色交响',
    artist: '鸿蒙乐团',
    album: '开源之声 Vol.1',
    duration: 312,
    url: 'resources/rawfile/audio/track002.mp3',
    mediaType: MediaType.AUDIO
  },
  {
    id: 'audio_003',
    title: '星河漫步',
    artist: 'OpenHarmony',
    album: '开源之声 Vol.2',
    duration: 198,
    url: 'resources/rawfile/audio/track003.mp3',
    mediaType: MediaType.AUDIO
  },
  {
    id: 'audio_004',
    title: '数字狂想曲',
    artist: '科技之声',
    album: '开源之声 Vol.2',
    duration: 267,
    url: 'resources/rawfile/audio/track004.mp3',
    mediaType: MediaType.AUDIO
  },
  {
    id: 'audio_005',
    title: '开源之歌',
    artist: '社区合唱团',
    album: '特别企划',
    duration: 185,
    url: 'resources/rawfile/audio/track005.mp3',
    mediaType: MediaType.AUDIO
  }
];

// 演示视频数据
const demoVideoTracks: MediaItem[] = [
  {
    id: 'video_001',
    title: 'OpenHarmony 4.1 新特性介绍',
    artist: '官方团队',
    duration: 600,
    url: 'resources/rawfile/video/demo001.mp4',
    mediaType: MediaType.VIDEO
  },
  {
    id: 'video_002',
    title: 'ArkTS 进阶教程',
    artist: '技术布道师',
    duration: 1200,
    url: 'resources/rawfile/video/demo002.mp4',
    mediaType: MediaType.VIDEO
  },
  {
    id: 'video_003',
    title: '分布式能力演示',
    artist: '开发者社区',
    duration: 480,
    url: 'resources/rawfile/video/demo003.mp4',
    mediaType: MediaType.VIDEO
  },
  {
    id: 'video_004',
    title: '应用性能优化实践',
    artist: '架构组',
    duration: 900,
    url: 'resources/rawfile/video/demo004.mp4',
    mediaType: MediaType.VIDEO
  }
];

八、关键踩坑与总结

在本次集成过程中,有以下几个关键点值得特别注意:

1. AVPlayer 事件监听 API。 鸿蒙 4.x 版本的 AVPlayer 事件名称与早期文档描述有所差异。playbackState 应改为 stateChangemediaError 改为 errorplaybackRateChange 改为 speedDone。建议以 @kit.MediaKit 包的实际导出为准。

2. seek 方法单位。 AVPlayer.seek() 的参数单位是毫秒,而数据模型中 durationcurrentTime 的单位是,务必做好单位换算,否则进度条会偏差 1000 倍。

3. 触摸事件坐标。 ArkUI 的 TouchEvent 中,触摸点坐标应通过 event.touches[0].x 获取,而非 event.x。在跨分辨率适配时,还需考虑实际组件宽度而非固定像素值。

4. Builder 调用链式写法。 @Builder 方法的返回值是 void,无法像普通组件那样链式调用 .margin().padding()。需要在 Builder 内部将样式属性附加到根组件上。

5. 全屏切换时机。 屏幕方向切换应在 aboutToAppear 中执行,而非 build() 方法中,否则可能导致布局抖动。


九、代码仓库

本文完整工程已同步至 AtomGit 平台:

仓库地址: https://atomgit.com/openharmony/flutter-ohos-media-player-demo

仓库包含完整的 ArkTS 源码、资源文件配置以及 Flutter 集成示例,读者可 clone 后直接在 DevEco Studio 中运行验证。


十、效果截图

[图 1] 主页 Index 页面,底部展示了媒体播放器入口卡片,深色卡片设计。卡片包含音乐图标、标题「Media Player」、副标题「音频 & 视频播放器」以及右上角的播放按钮。
在这里插入图片描述

[图 2] 媒体播放器页面 — 音频 Tab。展示当前播放曲目「晨曦轻语」的播放控件,包括进度条、时间显示、播放/暂停、上一首/下一首、快退/快进、随机播放与循环模式按钮。底部为播放列表。
在这里插入图片描述

[图 3] 媒体播放器页面 — 视频 Tab。视频播放区占据主要视觉空间,下方展示进度条、播放控制栏与视频播放列表。
在这里插入图片描述

[图 4] 媒体播放器页面 — 播放列表 Tab。展示「我的音乐」和「我的视频」两个播放列表卡片,显示各列表中的曲目数量与时长统计。
在这里插入图片描述

[图 5] 视频全屏播放模式。切换为横屏布局后,视频画面最大化展示,底部保留半透明渐变遮罩的控制栏,包含播放/暂停与上一首/下一首按钮。
在这里插入图片描述


结语

本文从工程架构设计出发,逐步深入数据模型、服务层与 UI 层的实现细节,系统地讲解了如何在 Flutter for OpenHarmony 跨平台工程中集成音视频播放能力。通过 MediaKit 的 AVPlayer 与 Flutter 的混合渲染架构,我们既保留了 Flutter 跨平台开发的效率优势,又充分发挥了 ArkTS 原生能力的性能与系统集成深度。

音视频能力的建设是一个持续迭代的过程,后续可进一步探索的方向包括:媒体资源下载与缓存、后台音频播放与通知栏控制、音视频录制与实时流媒体等。希望本文能为正在探索 OpenHarmony 跨平台开发的读者提供有价值的参考。

感谢各位阅读!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐