引言:那个“千篇一律”的播放器带来的困扰

上周,团队里的小王正在为一个知识分享类应用开发视频播放模块。产品经理对播放器的体验提出了明确要求:控制栏必须与应用简洁、专业的品牌调性一致,不能使用系统默认的、带有强烈平台风格的控件。

“这应该不难吧?”小王心想。他熟练地使用了Video组件,默认的控制栏功能齐全——播放、暂停、进度条、全屏一应俱全。然而,UI设计师发来的设计稿却让他犯了难:进度条是自定义的纤细线条,时间显示要放在右侧,全屏按钮的图标需要替换,甚至还需要一个默认隐藏、滑动时才出现的亮度/音量调节面板。

小王尝试了修改样式,但发现Video组件自带的控制器样式修改非常有限。他找到controls属性,将其设为false,准备完全自己实现。可接下来该怎么做?播放、暂停的逻辑好说,但进度条如何与视频播放时间精确同步?如何在横屏时正确切换屏幕方向?那个不常见的、纵向滑动的音量条又该如何实现?

更麻烦的是,测试时发现,在快速拖动自定义进度条时,视频画面会有轻微的“跳帧”感,不够流畅。产品经理试用后说:“这个播放器看起来有点‘简陋’,和我们应用的质感不匹配。”

小王盯着那个“不听话”的视频播放器,心里只有一个问题:在HarmonyOS中,如何彻底告别默认的控制栏,从零开始构建一个既美观又功能完备、体验流畅的自定义视频控制器?

今天,我们就来系统性地解决这个让视频播放体验“更上一层楼”的定制化难题。

背景知识

要构建一个优秀的自定义控制栏,首先要理解HarmonyOS中Video组件的“权力中枢”与“信息通道”:

核心角色

关键API/属性

在自定义控制栏中的职责

指挥官 (Controller)

VideoController

控制播放行为的执行者。所有播放指令(开始、暂停、跳转、停止)都通过它来下达。自定义控制栏上的按钮,本质上是调用其start(), pause(), setCurrentTime()等方法。

情报员 (回调事件)

onPrepared, onUpdate

提供视频状态信息的监听者onPrepared在视频准备就绪时触发,可获取总时长(duration)。onUpdate在播放过程中周期性触发,提供当前播放位置(time)。这是实现进度条同步和剩余时间计算的唯一数据来源

画布 (组件本身)

Video组件,controls: false

视频内容的渲染载体。将controls属性设为false是自定义的起点,它隐藏了系统默认的UI,将视觉控制权完全交给开发者。

外援 (其他模块)

AVVolumePanel, window

实现扩展功能的专家AVVolumePanel用于创建系统级的音量调节面板,window模块用于控制屏幕方向(横竖屏切换)。

简单来说,VideoController让你“指挥”视频,各种on*回调告诉你视频“发生了什么”,而controls: false给了你一张自由绘制的“画布”。理解这三者的分工与合作,是构建任何自定义控制功能的基础。

分析结论

小王遇到的问题,是许多开发者在追求独特播放器UI时的典型挑战。其核心可以归结为以下四个关键场景,以及对应的技术实现思路:

  1. 进度条同步问题:这是自定义控制栏的核心与难点。难点不在于绘制一个Slider,而在于如何实现视频进度与Slider滑块位置的双向、实时、无跳帧的同步。这需要:

    • 监听 -> 更新:通过onUpdate回调获取当前播放时间,实时更新Slider的value

    • 交互 -> 跳转:监听Slider的onChange事件,当用户拖动时,调用controller.setCurrentTime(value)进行精准跳转。这里的挑战在于如何避免在快速拖动时因频繁跳转导致的卡顿或跳帧

  2. 时间显示问题:需要同时展示“当前时间”和“剩余时间”(或总时间)。onUpdate提供了当前时间,onPrepared提供了总时长。剩余时间即是 总时长 - 当前时间。关键在于将毫秒数格式化为MM:SSHH:MM:SS的友好形式。

  3. 音量/亮度调节问题:这是一个视觉与系统功能结合的挑战。单纯绘制一个滑动条并不难,难点在于如何将这个滑动条的数值与系统真实的音量或屏幕亮度关联起来。HarmonyOS提供了AVVolumePanel这个专用组件,它可以创建一个独立于UI的系统音量面板,通过绑定其volumeLevel属性,可以优雅地解决音量控制问题。

  4. 横竖屏切换问题:这涉及应用窗口控制。目标不是让视频“拉伸”填满,而是改变整个应用窗口的显示方向。这需要通过window.getLastWindow()获取窗口对象,并调用setPreferredOrientation()方法来实现。需要正确处理全屏状态与屏幕方向的关系。

结论显而易见:自定义Video控制栏是一个组合性任务,它需要将Video组件的基础控制、状态监听与ArkUI的其他通用组件(Slider、Text、Button)及系统服务(窗口、音频)有机结合起来。每个功能点都有明确的技术路径。

解决方案

下面,我们提供一个涵盖上述四个核心场景的完整解决方案,并提供可直接使用的代码示例。

场景一:自定义进度条 (双向绑定与同步)

实现一个平滑的进度条,需要处理好“视频驱动进度条”和“用户拖动进度条驱动视频”这两个方向的数据流。

// 关键代码片段
@State currentTime: number = 0; // 当前播放时间(秒)
@State durationTime: number = 0; // 视频总时长(秒)
private controller: VideoController = new VideoController();

build() {
  Column() {
    Video({
      src: $rawfile('demo.mp4'),
      controller: this.controller
    })
    .controls(false) // 1. 隐藏默认控制栏
    .onPrepared((event) => { // 2. 获取视频总时长
      if (event) {
        this.durationTime = event.duration;
      }
    })
    .onUpdate((event) => { // 3. 实时监听播放进度
      if (event) {
        this.currentTime = event.time; // 事件驱动UI更新
      }
    })
    .width('100%')
    .height(300)

    // 4. 自定义Slider作为进度条
    Slider({
      value: this.currentTime, // 绑定当前时间
      min: 0,
      max: this.durationTime || 1 // 避免除零错误
    })
    .width('100%')
    .onChange((value: number) => { // 5. 用户拖动时跳转视频
      // 添加节流或判断,避免onChange过程中过于频繁的跳转
      this.controller.setCurrentTime(value);
    })
  }
}

实现要点

  • onUpdate是驱动进度条前进的“心跳”。

  • SlideronChange事件在用户拖动过程中会连续触发。直接在此回调中调用setCurrentTime可能会导致播放引擎忙于跳转而卡顿。一种优化策略是使用onChange事件,它仅在用户结束拖动时触发一次,体验更佳,但会损失拖拽预览效果。可根据需求选择。

场景二:自定义时间显示

在进度条旁显示当前时间和剩余时间。

@State restTime: number = 0; // 剩余时间

// 在 onUpdate 中计算剩余时间
.onUpdate((event) => {
  if (event) {
    this.currentTime = event.time;
    this.restTime = this.durationTime - event.time; // 计算剩余时间
  }
})

// 在布局中添加时间文本
Row() {
  // 当前时间
  Text(this.formatTime(this.currentTime))
    .fontSize(12)
    .fontColor(Color.White)
  // 进度条...
  Slider({...})
  // 剩余时间
  Text(`-${this.formatTime(this.restTime)}`) // 显示为“-01:30”格式
    .fontSize(12)
    .fontColor(Color.White)
}

// 时间格式化工具函数
private formatTime(seconds: number): string {
  const min = Math.floor(seconds / 60);
  const sec = Math.floor(seconds % 60);
  return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}

场景三:自定义音量调节 (使用AVVolumePanel)

实现一个点击按钮后,弹出系统风格音量滑块的效果。

import { AVVolumePanel } from '@kit.AudioKit';

@State voiceValue: number = 5; // 音量等级
@State showVolumePanel: boolean = false;

build() {
  Stack() {
    // ... Video 和其他组件

    // 音量按钮
    Button('音量')
      .onClick(() => {
        this.showVolumePanel = true;
      })

    // 系统音量面板
    if (this.showVolumePanel) {
      AVVolumePanel({
        volumeLevel: this.voiceValue, // 绑定音量值
        volumeParameter: {
          position: { x: 100, y: 200 } // 面板显示位置
        }
      })
      .onClick(() => {
        this.showVolumePanel = false; // 点击面板外关闭
      })
    }
  }
}

实现要点AVVolumePanel是一个系统级组件,样式与系统设置中的一致,体验良好。通过控制其显示/隐藏来实现点击调出。

场景四:自定义横竖屏切换

通过控制应用窗口方向来实现真正的横竖屏切换。

import { window } from '@kit.ArkUI';

@State isFullScreen: boolean = false;

// 全屏/退出按钮
Text(this.isFullScreen ? '退出全屏' : '全屏')
  .onClick(() => {
    this.isFullScreen = !this.isFullScreen;
    this.changeOrientation(this.isFullScreen);
  })

// 切换屏幕方向的方法
private changeOrientation(isLandscape: boolean) {
  // 获取当前窗口并设置首选方向
  window.getLastWindow(this.getUIContext().getHostContext())
    .then((lastWindow: window.Window) => {
      lastWindow.setPreferredOrientation(
        isLandscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT
      );
    })
    .catch((err: Error) => {
      console.error('切换屏幕方向失败: ', err);
    });
}

实现要点:此操作改变的是整个窗口的方向,而非仅仅视频组件。通常与全屏状态绑定。注意,在横屏时可能需要配合沉浸式API隐藏状态栏和导航栏,以获得更完整的观影体验。

完整示例代码集成

以下是一个集成了播放/暂停、自定义进度条、时间显示、音量控制和横竖屏切换的简单自定义控制栏示例:

import { window } from '@kit.ArkUI';
import { AVVolumePanel } from '@kit.AudioKit';

@Entry
@Component
struct CustomVideoControlDemo {
  // 视频控制与数据
  private controller: VideoController = new VideoController();
  @State currentTime: number = 0;
  @State durationTime: number = 0;
  @State restTime: number = 0;
  @State isPlaying: boolean = false;

  // 扩展功能
  @State isFullScreen: boolean = false;
  @State voiceValue: number = 5;
  @State showVolumePanel: boolean = false;

  // 时间格式化
  private formatTime(s: number): string {
    const min = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    return `${min}:${sec.toString().padStart(2, '0')}`;
  }

  // 切换屏幕方向
  private changeOrientation(landscape: boolean) {
    window.getLastWindow(this.getUIContext().getHostContext())
      .then((win) => {
        win.setPreferredOrientation(landscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT);
      });
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      // 1. 视频组件
      Video({
        src: $rawfile('videoTest.mp4'),
        controller: this.controller
      })
      .controls(false)
      .autoPlay(false)
      .loop(false)
      .onPrepared((e) => {
        if (e) {
          this.durationTime = e.duration;
          this.restTime = e.duration;
        }
      })
      .onUpdate((e) => {
        if (e) {
          this.currentTime = e.time;
          this.restTime = this.durationTime - e.time;
        }
      })
      .width('100%')
      .height(300)

      // 2. 自定义控制栏 (半透明背景)
      Column({ space: 10 }) {
        // 进度条与时间
        Row() {
          Text(this.formatTime(this.currentTime)).fontSize(12).fontColor(Color.White).width(40)
          Slider({
            value: this.currentTime,
            min: 0,
            max: this.durationTime || 1
          })
          .layoutWeight(1)
          .onChange((v) => { this.controller.setCurrentTime(v); })
          Text(`-${this.formatTime(this.restTime)}`).fontSize(12).fontColor(Color.White).width(40)
        }
        .width('100%')

        // 控制按钮行
        Row({ space: 20 }) {
          // 播放/暂停
          Button(this.isPlaying ? '❚❚' : '▶')
            .fontSize(18)
            .fontColor(Color.White)
            .backgroundColor(Color.Transparent)
            .onClick(() => {
              this.isPlaying = !this.isPlaying;
              if (this.isPlaying) {
                this.controller.start();
              } else {
                this.controller.pause();
              }
            })
          // 音量
          Button('🔊')
            .onClick(() => { this.showVolumePanel = true; })
          // 全屏
          Button(this.isFullScreen ? '⇲' : '⇱')
            .onClick(() => {
              this.isFullScreen = !this.isFullScreen;
              this.changeOrientation(this.isFullScreen);
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .padding(10)
      .backgroundColor('rgba(0,0,0,0.5)')
      .zIndex(1)

      // 3. 音量面板 (点击按钮后出现)
      if (this.showVolumePanel) {
        AVVolumePanel({
          volumeLevel: this.voiceValue,
          volumeParameter: { position: { x: 50, y: 150 } }
        })
        .onClick(() => { this.showVolumePanel = false; })
        .zIndex(2)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.Black)
  }
}

注意事项

  1. onUpdate回调频率onUpdate的触发频率可能很高。避免在此回调中执行复杂或耗时操作,以免影响UI渲染性能。

  2. 进度条跳转优化:如前所述,直接响应Slider的onChange(拖拽中)可能导致性能问题。对于长视频,可以考虑在拖拽时只更新一个“预览时间”,在onChange(拖拽结束)时再执行实际跳转。

  3. 横屏与沉浸式:调用setPreferredOrientation实现横屏后,手机的状态栏和导航栏可能仍然可见。如果需要真正的“沉浸式”全屏体验,需要额外调用window模块的相关方法(如setWindowSystemBarEnable)来隐藏系统栏。

  4. AVVolumePanel的位置volumeParameter.position指定的坐标是相对于整个屏幕的。需要根据你的UI布局精心计算,以确保面板出现在合适的位置。

  5. 资源释放:在组件销毁时,如果通过createAVPlayer等底层方式创建了播放器,务必调用对应的释放方法,防止内存泄漏。使用Video组件通常无需此操作。

总结

回顾小王的故事,他从面对一个“黑箱”般的默认播放器,到亲手搭建起一个功能完整、体验可控的自定义控制栏。通过本文的剖析,我们明确了:

  • 核心是“状态驱动”:自定义控制栏的本质,是构建一个由视频状态(onUpdate, onPrepared)驱动UI,并由UI交互(按钮点击、滑块拖动)反向控制视频状态(VideoController)的双向控制环

  • 关键在于“组合与桥接”:HarmonyOS已将复杂功能模块化。我们的工作是将Video组件、SliderText等通用UI组件,以及windowAVVolumePanel等系统服务,通过ArkUI的声明式语法和事件回调“桥接”起来,各司其职。

  • 目标是“极致的用户体验”:通过自定义,我们不仅能匹配应用视觉风格,更能深入控制交互细节(如进度条拖拽反馈、音量面板触发逻辑),打造独一无二的、与产品气质深度融合的播放体验。

从此,视频播放器的外观与交互不再是“将就”的默认选项,而是一个可以尽情发挥设计才能、提升产品质感的舞台。希望本文能帮助你像资深多媒体开发工程师一样,深入理解视频播放的掌控之道,在你的HarmonyOS应用中呈现令人过目不忘的视听体验。

Logo

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

更多推荐