【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(二十二) | 多媒体 | AVPlayer嵌入教学视频——让智慧屏真正“活”起来

HarmonyOS 6.1 全场景实战|灵犀厨房 | 多媒体 | AVPlayer嵌入教学视频------让智慧屏真正"活"起来

摘要 :在《分布式流转让菜谱"飞":手机选、平板看、智慧屏播的全场景秘诀》篇章中,我们实现了「手机选菜谱→平板看步骤→智慧屏播视频」的分布式流转,但智慧屏上的视频播放还是静态的「模拟区」------点击只会显示一个 LoadingProgress,没有实际的视频内容。本篇将利用 HarmonyOS 6.1.0 的 AVPlayer(@kit.MediaKit),替换模拟区域为真实的视频播放器,让流转到智慧屏后能真正播放烹饪教学视频,把《灵犀厨房》的全场景体验推上一个新台阶。


一、为什么需要真实 AVPlayer?

回顾《分布式流转让菜谱"飞":手机选、平板看、智慧屏播的全场景秘诀》的智慧屏布局:

typescript 复制代码
// 旧版:模拟视频区域 ------ 只有 LoadingProgress,不是真播放
if (this.isVideoPlaying) {
  Column({ space: 12 }) {
    LoadingProgress().width(48).height(48).color('#FF6B35')
    Text('正在播放烹饪教学...').fontSize(18)
  }
}

这个「模拟」实现有三个致命问题:

问题 影响 用户感知
不是真实播放 没有视频画面,只有一个转圈动画 "这视频怎么一直在加载?"
没有播放控制 无法暂停、重播、查看进度 "我想倒回去看重播怎么办?"
无法接入真实内容 后端返回的视频 URL 无法被消费 "难道我只能看这个假的?"

🎯 本篇目标 :用 HarmonyOS 的 AVPlayer + XComponent 替换模拟区,实现完整的视频播放能力------加载→播放→暂停→重播→错误处理。用户流转到智慧屏后,看到的不再是一个假的加载动画,而是真正的烹饪教学视频画面。


二、核心原理:AVPlayer 与 XComponent 的"双引擎"协作

2.1 AVPlayer 在 HarmonyOS 架构中的位置

图一解读 :AVPlayer 本身是**无头(headless)**的播放器------它负责解码和音画同步,但不提供 UI 渲染容器。XComponent 组件提供了一个 SURFACE 类型的原生渲染画布,AVPlayer 将解码后的视频帧渲染到这个画布上。两者通过 surfaceId 建立连接------就像放映机(AVPlayer)需要银幕(XComponent)才能让观众看到画面。

2.2 AVPlayer 状态机

图二解读 :AVPlayer 的状态机看似复杂,但核心路径只有一条:idle → initialized → prepared → playing。其他状态都是从这条主路径分支出来的。理解这个状态机是处理各种边界情况的关键------比如播放完成后要重播,不能直接 play(),必须先 seek(0) 回到开头;释放播放器前必须先解绑事件监听,否则可能内存泄漏。

与 Web 的 <video> 标签不同,HarmonyOS 的 AVPlayer 不给开发者提供"自动播放"选项------你必须先调用 prepare(),等它进入 prepared 状态后,再手动调用 play()。这是一种"防御性设计":避免页面一加载就自动播放视频、消耗用户流量的糟糕体验。

2.3 异步时序问题:谁先就绪?

AVPlayer 创建和 XComponent 就绪是两条独立的异步路径,谁先完成不确定:

复制代码
时间线 A:  AVPlayer 创建完成  ──→  等待 surface  ──→  收到 surfaceId  ──→  bind + prepare
时间线 B:  XComponent.onLoad ──→  获取 surfaceId  ──→  等待 AVPlayer  ──→  bind + prepare

这就像两个快递员------你不知道谁会先到。如果 AVPlayer 先到,它需要等 XComponent 准备好 surface;如果 XComponent 先到,它需要等 AVPlayer 创建完成。

解决方案bindSurfaceAndPrepare() 方法在两条路径的末端均被调用,互补确保绑定一定发生:

typescript 复制代码
// 路径 A:initAVPlayer() 末尾
private async initAVPlayer(): Promise<void> {
  this.avPlayer = await media.createAVPlayer();
  this.avPlayer.url = videoUrl;
  // ... 注册回调 ...
  this.bindSurfaceAndPrepare();  // ← 若 surface 已就绪,立即 bind + prepare
}

// 路径 B:XComponent.onLoad
XComponent({ ... })
  .onLoad(() => {
    this.videoSurfaceId = this.xComponentController.getXComponentSurfaceId();
    this.bindSurfaceAndPrepare();  // ← 若 AVPlayer 已创建,立即 bind + prepare
  })

bindSurfaceAndPrepare() 内部做了双重守卫

typescript 复制代码
private bindSurfaceAndPrepare(): void {
  if (!this.avPlayer || this.videoSurfaceId.length === 0) return;  // 任一条件不满足→退出
  if (this.avPlayerState === VideoPlayerState.LOADING) {            // 仅 LOADING 状态执行
    this.avPlayer.surfaceId = this.videoSurfaceId;
    this.avPlayer.prepare();
  }
}

这种设计不关心"谁先到",只关心"两个都到了"。无论异步时序如何,只要 AVPlayer 和 surfaceId 都就绪,绑定就会发生------且只发生一次。


三、数据模型变更:添加 videoUrl

在替换模拟播放器之前,需要先让菜谱数据支持视频 URL。

3.1 Recipe 接口扩展

typescript 复制代码
// foundation/model/Recipe.ets(entry + shared 同步修改)
export interface Recipe {
  // ... 现有字段 ...
  /** 教学视频 URL(流转到智慧屏时播放) */
  videoUrl?: string;
}

videoUrl 设为可选(?:),原因:

  • 并非每道菜谱都有视频------只有标记了「视频教学」的菜谱才配置
  • 向前兼容:已有菜谱不需要修改即可编译通过
  • 智慧屏 UI 已处理「暂无视频」的空状态展示

3.2 MockData 补充

为 10 道菜谱统一配置测试视频 URL:

typescript 复制代码
{
  id: 1, name: '番茄牛腩煲',
  // ... 其他字段 ...
  videoUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4'
}

说明 :测试 URL 使用的是 W3C 提供的 Sintel 预告片(公有领域),适合开发调试。生产环境中,videoUrl 应由后端 API 返回每道菜谱的真实教学视频地址。


四、代码实现详解

4.1 状态枚举

typescript 复制代码
enum VideoPlayerState {
  IDLE = 'idle',           // 未初始化 / 已释放
  LOADING = 'loading',     // 创建中 / 等待 surface
  READY = 'ready',         // prepare 完成,可播放
  PLAYING = 'playing',     // 播放中
  PAUSED = 'paused',       // 已暂停
  COMPLETED = 'completed', // 播放完成
  ERROR = 'error'          // 错误
}

设计决策 :为什么用字符串枚举而非数字?因为字符串在日志中可读------当你看到 [AVPlayer] 状态变更: idle → playing,比 0 → 3 直观得多。这在调试异步播放问题时尤其重要。

4.2 initAVPlayer() ------ 播放器初始化

typescript 复制代码
private async initAVPlayer(): Promise<void> {
  this.releaseAVPlayer();  // ← 先释放旧实例(幂等)

  const videoUrl = this.recipe.videoUrl ?? DEFAULT_VIDEO_URL;
  if (!videoUrl) {
    this.avPlayerState = VideoPlayerState.IDLE;  // 无视频→保持空闲
    return;
  }

  this.avPlayerState = VideoPlayerState.LOADING;

  try {
    this.avPlayer = await media.createAVPlayer();
    this.avPlayer.url = videoUrl;

    // 注册五大事件回调
    this.avPlayer.on('stateChange', (state) => this.handleAVPlayerStateChange(state));
    this.avPlayer.on('timeUpdate', (time) => { this.videoCurrentTime = time; });
    this.avPlayer.on('durationUpdate', (duration) => { this.videoDuration = duration; });
    this.avPlayer.on('error', (err) => {
      this.avPlayerState = VideoPlayerState.ERROR;
      this.videoError = `播放异常 (code: ${err.code})`;
    });
    this.avPlayer.on('bufferingUpdate', (_, value) => {
      this.videoIsBuffering = value < 100;
    });

    // 如果 XComponent 已就绪,立即绑定 surface
    this.bindSurfaceAndPrepare();
  } catch (err) {
    this.avPlayerState = VideoPlayerState.ERROR;
    this.videoError = '播放器初始化失败';
  }
}

核心点解读 :入口处的 releaseAVPlayer() 是幂等的------如果 avPlayer 为 null,什么都不做。这保证了 initAVPlayer 可以被多次安全调用(比如用户点击重试按钮时),不会产生多个播放器实例。

4.3 handleAVPlayerStateChange() ------ 状态映射

typescript 复制代码
private handleAVPlayerStateChange(state: string): void {
  switch (state) {
    case 'prepared':  this.avPlayerState = VideoPlayerState.READY; break;
    case 'playing':   this.avPlayerState = VideoPlayerState.PLAYING;
                      this.videoIsBuffering = false; break;
    case 'paused':    this.avPlayerState = VideoPlayerState.PAUSED; break;
    case 'completed': this.avPlayerState = VideoPlayerState.COMPLETED; break;
    case 'stopped':   this.avPlayerState = VideoPlayerState.IDLE; break;
    case 'error':     this.avPlayerState = VideoPlayerState.ERROR;
                      this.videoError = '播放过程中发生错误'; break;
  }
}

为什么没有 initializedreleased

  • initialized:URL 设置成功后的瞬时状态,用户不需要知道"播放器初始化了",他们只关心"能播了吗"
  • released:player 已释放,此时 avPlayer 引用已置 null,UI 不需要处理

4.4 togglePlayPause() ------ 播放控制

typescript 复制代码
private togglePlayPause(): void {
  if (!this.avPlayer) return;

  if (this.avPlayerState === VideoPlayerState.PLAYING) {
    this.avPlayer.pause();                         // 播放中→暂停
  } else if (this.avPlayerState === VideoPlayerState.READY ||
             this.avPlayerState === VideoPlayerState.PAUSED ||
             this.avPlayerState === VideoPlayerState.COMPLETED) {
    if (this.avPlayerState === VideoPlayerState.COMPLETED) {
      this.avPlayer.seek(0, media.SeekMode.SEEK_PREV_SYNC);  // 播完→回到开头
    }
    this.avPlayer.play();                          // 开始/恢复播放
  }
}

设计决策 :重播不用 release + init,而是 seek(0) + play()。因为 prepare 是最耗时的阶段(需要解析视频头、建立解码器),而 seek 是毫秒级的。重播体验应该像按"重播键"一样瞬间响应,而不是像重新加载一样等待。

4.5 releaseAVPlayer() ------ 资源释放

typescript 复制代码
private releaseAVPlayer(): void {
  if (this.avPlayer) {
    this.avPlayer.off('stateChange');
    this.avPlayer.off('timeUpdate');
    this.avPlayer.off('durationUpdate');
    this.avPlayer.off('error');
    this.avPlayer.off('bufferingUpdate');
    this.avPlayer.release();
    this.avPlayer = null;
  }
  this.avPlayerState = VideoPlayerState.IDLE;
  this.videoCurrentTime = 0;
  this.videoDuration = 0;
  this.videoError = '';
  this.videoIsBuffering = false;
  this.videoSurfaceId = '';
}

关键 :必须先 offrelease。如果在 release 后再 off,事件监听器可能已被系统清理,调用 off 会抛出异常。这就像退房前必须先归还钥匙------顺序错了,就会出问题。

4.6 生命周期集成点

typescript 复制代码
// ── 进入智慧屏模式 ──
private async onDeviceSelected(device: FlowDevice): Promise<void> {
  if (device.type === FlowDeviceType.SMART_SCREEN) {
    this.initAVPlayer();  // ← 流转到智慧屏时立即初始化
  }
}

// ── 离开智慧屏模式 ──
private async backToLocal(): Promise<void> {
  this.releaseAVPlayer();  // ← 返回手机时释放播放器
}

// ── 页面销毁 ──
aboutToDisappear(): void {
  this.releaseAVPlayer();  // ← 页面退出时释放(兜底)
}

三重释放保障:正常返回(backToLocal)、页面销毁(aboutToDisappear)、重新初始化(initAVPlayer 入口)。任意一个触发都能保证播放器被正确释放,不会内存泄漏。


五、智慧屏 UI:XComponent + 状态叠加层

5.1 整体布局结构

复制代码
┌────────────────────────────────────┐
│  📺 已流转到智慧屏 X5    [返回手机] │ ← 顶部栏
├────────────────────────────────────┤
│  ┌──────────────────────────────┐ │
│  │     XComponent (视频画面)     │ │ ← 视频播放区 (45% 高度)
│  │  ┌────────────────────────┐  │ │
│  │  │   状态叠加层 (Stack)    │  │ │   LOADING: 加载动画
│  │  └────────────────────────┘  │ │   READY: 播放按钮
│  └──────────────────────────────┘ │   COMPLETED: 重播按钮
│                                    │   ERROR: 错误+重试
│  ▶️ 00:32 / 02:15    🍳番茄牛腩煲  │ ← 播放控制栏
├────────────────────────────────────┤
│          第 1 步                    │
│     焯水:牛腩切块冷水入锅...       │ ← 当前步骤大字展示
├────────────────────────────────────┤
│  [← 上一步]  Step 1/3  [下一步 →] │ ← 底部导航
└────────────────────────────────────┘

实际的效果图:

5.2 状态与 UI 映射表

AVPlayerState 叠加层显示 点击行为 控制栏显示
IDLE 暂无教学视频 / 占位提示
LOADING LoadingProgress + "正在加载视频..."
READY ▶️ 播放按钮 + 菜谱名 点击→播放
PLAYING 透明触摸层(点击暂停) 点击→暂停
PAUSED ▶️ 播放按钮(已暂停) 点击→继续
COMPLETED ✅ 播放完成 + 点击重播 点击→重播
ERROR ⚠️ 错误信息 + 重试按钮 点击→重试

5.3 状态叠加层的设计考量

播放中为什么是"透明触摸层"而非"暂停按钮"?

在智慧屏场景下,用户距离屏幕较远(通常 2-3 米)。如果只放一个小暂停按钮,用户很难精准点击。整个视频区域就是一个大暂停按钮 ------点击画面任意位置即可暂停,再次点击恢复播放。这是大屏交互的黄金法则:可点击区域越大,用户体验越好

5.4 播放控制栏

在视频区域下方显示播放进度和控制:

typescript 复制代码
if (this.avPlayerState === VideoPlayerState.PLAYING ||
    this.avPlayerState === VideoPlayerState.PAUSED ||
    this.avPlayerState === VideoPlayerState.READY) {
  Row({ space: 12 }) {
    // 播放/暂停按钮
    Button() {
      if (this.avPlayerState === VideoPlayerState.PLAYING) {
        SymbolGlyph($r('sys.symbol.pause_fill')).fontSize(20)
      } else {
        SymbolGlyph($r('sys.symbol.play_fill')).fontSize(20)
      }
    }.onClick(() => this.togglePlayPause())

    // 时间显示
    Text(`${this.formatTime(this.videoCurrentTime)} / ${this.formatTime(this.videoDuration)}`)
      .fontSize(12).fontColor('#AAA')

    Blank()

    // 菜谱名称
    Text(`🍳 ${this.recipe.name}`).fontSize(12).fontColor('#FF6B35')
  }
}

六、代码增删改清单

文件 新增/修改 说明
entry/.../foundation/model/Recipe.ets 修改 添加 videoUrl?: string 字段
shared/.../foundation/model/Recipe.ets 修改 HSP 共享模块同步添加 videoUrl
entry/.../foundation/model/MockData.ets 修改 10 道菜谱添加测试视频 URL
shared/.../foundation/model/MockData.ets 修改 HSP 共享模块同步添加
pages/RecipeDetailPage.ets 重大修改 新增 AVPlayer 集成(v4 → v5)

RecipeDetailPage 变更详情

变更项 行数 说明
新增 import { media } from '@kit.MediaKit' 1 系统多媒体 API
新增 VideoPlayerState 枚举 + DEFAULT_VIDEO_URL 16 7 态状态枚举、默认视频 URL
新增 AVPlayer 状态变量 10 avPlayerxComponentControllervideoSurfaceId
修改 onDeviceSelected() +4 流转到智慧屏时调用 initAVPlayer()
修改 backToLocal() +2 返回手机时调用 releaseAVPlayer()
修改 aboutToDisappear() +2 页面销毁时兜底释放
重写 buildSmartScreenLayout() ~50 模拟区→XComponent + 状态叠加层 + 控制栏
新增 AVPlayer 方法集 ~120 initAVPlayerreleaseAVPlayertogglePlayPausebindSurfaceAndPrepare

七、设计决策

决策 选择 理由
AVPlayer 放在哪一层 UI 层(RecipeDetailPage 内部) AVPlayer 与 XComponent 强绑定,生命周期与页面一致;无复用场景,不需要抽 Business 层
视频 source url 属性(HTTP/HTTPS) 支持网络视频,mock 使用 W3C 公开测试视频;生产环境由后端返回 CDN 地址
surface 绑定时机 双路径互补 解决 AVPlayer 与 XComponent 异步就绪的时序问题
播放控制方式 全屏点击叠加层 智慧屏远距离交互,大面积点击区域比小按钮更友好
重播实现 seek(0) + play() 比重新 prepare 快得多,用户体验更好
错误恢复 手动重试按钮 网络不稳定时自动重试可能陷入死循环
默认视频 DEFAULT_VIDEO_URL 常量 兜底策略,生产环境可改为空,显示「暂无视频」
时间格式化 自实现 formatTime(ms) 将毫秒转为 mm:ss 显示,简洁高效

八、运行验证与效果

  1. 将程序部署到真机或者手机模拟器。
  2. 点击首页中任意菜谱,进入详情页面。
  3. 在详情页中点击左上角的图标,在弹窗中选择智慧屏。
  4. 进入智慧屏点击播放,查看效果(暂未是真实做菜视频,先体验效果)。
  5. 在播放过程中,看到视频进度和底部的步骤是协同的模拟人在执行控制操作)。

九、本阶段总结与下篇预告

本篇我们将《灵犀厨房》智慧屏上的「模拟视频区」升级为了真正的 AVPlayer 视频播放器

  • 数据层 :为 Recipe 接口添加 videoUrl 可选字段,10 道菜谱均配置测试视频 URL
  • 播放器层:完整实现 AVPlayer 生命周期------创建→URL→surface绑定→prepare→播放控制→释放
  • UI 层:XComponent 渲染 + 7 态叠加层 + 播放控制栏,智慧屏交互体验完整
  • 异步时序 :双路径互补的 bindSurfaceAndPrepare() 设计,优雅解决异步就绪问题
  • 资源管理:三重释放保障,杜绝内存泄漏

现在的全场景体验

📱 手机选菜谱 → 📋 流转到平板看步骤 → 📺 流转到智慧屏,真·视频教学开始播放!

下篇预告:《交互动效:转场、列表动画与趣味反馈》。我们将为《灵犀厨房》的页面转场、列表滚动和按钮点击加入流畅的动画与趣味反馈,让 App 从"能用"进化为"好用"。


📚 本系列持续更新中:下一篇将为 App 注入动效灵魂,让交互如丝般顺滑。

🔗 专栏入口《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包包括第1-15篇所有代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对您有所帮助,麻烦您动动发财之手点赞 👍、收藏 ⭐ 和评论 💬。谢谢大家!!

纯血鸿蒙,用心造厨。我们下一篇见!

相关推荐
chenying9981791 小时前
扩散模型语音克隆:参考音频注入的五种方式
人工智能·音视频·语音合成
2023自学中3 小时前
imx6ull开发板 移植 ffmpeg 4.2.11 + x264 视频编码库
linux·ffmpeg·音视频·嵌入式·开发板
向宇it5 小时前
【AI视频】生成AI短剧、漫剧
人工智能·ai·音视频·动画·ai视频·短剧
一抹烟霞6 小时前
# 视频隐空间基础
人工智能·音视频
jbk33117 小时前
画面重构,字幕配音原创,彻底改变视频指纹暗水印,剪映二次视频创作关键技术教程分享
人工智能·音视频·剪辑软件·剪映自动化软件
hz567897 小时前
实时音视频SDK选型指南:TRTC、WebRTC与音视频PaaS能力对比
安全·音视频·webrtc·实时音视频·信息与通信·paas
EasyDSS7 小时前
私有化音视频系统/视频直播点播EasyDSS一体化音视频平台助力校园全场景数字化转型
音视频
南山有乔木7897 小时前
酷狗音乐如何转换MP3格式?kgg/kgm/kgma转mp3格式转换方法整理
音视频
searchforAI8 小时前
Ai好记 vs Get笔记:AI音视频笔记工具深度测评对比
人工智能·笔记·学习·ai·音视频·语音识别