写点什么

万字长文,手把手教你 2 小时实现鸿蒙版视频播放器(附源码),建议先收藏

作者:程序员潘Sir
  • 2025-09-19
    四川
  • 本文字数:33806 字

    阅读完需:约 111 分钟

万字长文,手把手教你2小时实现鸿蒙版视频播放器(附源码),建议先收藏

大家好,我是潘 Sir,持续分享 IT 技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新 AI+编程、企业级项目实战等原创内容、欢迎关注!


​ 随着移动互联网和 5G 网络的普及,短视频已成为人们日常生活中必不可少的娱乐工具。本文以视频播放功能为例,探讨基于 HarmonyOS 实现视频的播放和控制实现。文章较长,码了整整一天,建议先收藏!


ps:代码较多,可以先根据文章整体理清思路,然后评论区回复领取代码进行研究。

一、需求分析

1、应用场景

​ 视频播放场景很多:如老牌的视频网站(优酷、腾讯视频)、大家熟悉的 B 站以及短视频平台(抖音、视频号、快手等),播放器都是核心功能。


​ 基于以上场景,本文实现本地视频播放器功能,包括:视频加载、播放、暂停、退出,跳转播放,静音播放,循环播放,窗口缩放模式设置,倍速设置,音量设置,字幕挂载等功能。在鸿蒙操作系统(HarmonyOS)上借助 HarmonyOS SDK 提供媒体服务(Media Kit)中的 AVPlayer,开发鸿蒙原生应用 APP,完成上述功能。

2、实现效果

部分效果见下图:



  1. 项目运行成功后,视频自动开始播放;

  2. 点击暂停/播放按钮,控制视频暂停播放;

  3. 滑动视频进度条,视频跳转到指定位置,在视频中间会出现时间进度方便用户查看视频进度;

  4. 点击倍速,可以选择 1.0、1.25、1.75、2.0 进行倍速调节;

  5. 点击静音按钮,可以设置静音模式播放;

  6. 点击窗口缩放模式按钮,可以选择拉伸至与窗口等大、缩放至最短边填满窗口;

  7. 长按屏幕,控制视频 2.0 倍速播放;

  8. 上下滑动屏幕,可以设置视频播放音量;

  9. 视频下方显示字幕,并可以点击语言切换按钮切换字幕;

  10. 视频自动循环播放;

3、技术分析

​ HarmonyOS SDK 的媒体服务(Media Kit)中提供了 AVPlayer,可以实现音视频的播放控制。在界面上,通过 XComponent 组件实现画面的自定义渲染。

4、注意事项

  • 视频播放器无法使用预览方式,需要模拟器或真机。

  • 本案例中大部分功能可再模拟器使用,长按手势可以使用模拟器,但拖动手势需要真机才能使用。

二、理论知识

1、XComponent 组件

​ ArkUI 提供的 XComponent 组件作为一种渲染组件,可用于 EGL/OpenGLES 和媒体数据写入,通过使用 XComponent 持有的“NativeWindow”来渲染画面,通常用于满足开发者较为复杂的自定义渲染需求,例如相机预览流的显示和游戏画面的渲染


​ XComponent 组件可通过指定 type 字段来实现不同的渲染方式,分别为 XComponentType.SURFACE 和 XComponentType.TEXTURE。对于 SURFACE 类型,开发者将定制的绘制内容单独展示到屏幕上。对于 TEXTURE 类型,开发者将定制的绘制内容和 XComponent 组件的内容合成后展示到屏幕上。


​ XComponent 持有一个 Surface,开发者能通过调用 NativeWindow 等接口,申请并提交 Buffer 至图形队列,以此方式将自绘制内容传送至该 Surface。XComponent 负责将此 Surface 整合进 UI 界面,其中展示的内容正是开发者传送的自绘制内容。


​ Surface 的默认位置与大小与 XComponent 组件一致,开发者可利用 setXComponentSurfaceRect 接口自定义调整 Surface 的位置和大小。XComponent 组件负责创建 Surface,并通过回调将 Surface 的相关信息告知应用。应用可以通过一系列接口设定 Surface 的属性。该组件本身不对所绘制的内容进行感知,亦不提供渲染绘制的接口。

2、Media Kit 媒体服务

​ HarmonyOS SDK 提供的 Media Kit(媒体服务)用于开发音视频播放或录制的各类功能。在 Media Kit 提供的模块中,AVPlayer 用于播放音视频。


​ AVPlayer 主要工作是将 Audio/Video 媒体资源(比如 mp4/mp3/mkv/mpeg-ts 等)转码为可供渲染的图像和可听见的音频模拟信号,并通过输出设备进行播放。


​ AVPlayer 提供功能完善一体化播放能力,应用只需要提供流媒体来源,不负责数据解析和解码就可达成播放效果。

2.1 视频播放

​ 接下来重点说下视频播放功能,当使用 AVPlayer 开发视频应用播放视频时,AVPlayer 与外部模块的交互关系如图所示。



​ 应用通过调用 JS 接口层提供的 AVPlayer 接口实现相应功能时,框架层会通过播放服务(Player Framework)解析成单独的音频数据流和视频数据流,音频数据流经过软件解码后输出至音频服务(Audio Framework),再至硬件接口层的音频 HDI,实现音频播放功能。视频数据流经过硬件(推荐)/软件解码后输出至图形渲染服务(Graphic Framework),再输出至硬件接口层的显示 HDI,完成图形渲染。


​ 完整的视频播放需要:应用、XComponent、Player Framework、Graphic Framework、Audio Framework、显示 HDI 和音频 HDI 共同实现。


​ 图中的数字标注表示需要数据与外部模块的传递。


  1. 应用从 XComponent 组件获取窗口 SurfaceID。

  2. 应用把媒体资源、SurfaceID 传递给 AVPlayer 接口。

  3. Player Framework 把视频 ES 数据流输出给解码 HDI,解码获得视频帧(NV12/NV21/RGBA)。

  4. Player Framework 把音频 PCM 数据流输出给 Audio Framework,Audio Framework 输出给音频 HDI。

  5. Player Framework 把视频帧(NV12/NV21/RGBA)输出给 Graphic Framework,Graphic Framework 输出给显示 HDI。

2.2 支持的格式与协议

​ 推荐使用以下主流的播放格式,音视频容器、音视频编码属于内容创作者所掌握的专业领域,不建议应用开发者自制码流进行测试,以免产生无法播放、卡顿、花屏等兼容性问题。若发生此类问题不会影响系统,退出播放即可。

3、手势系统

手势是一系列基础事件不断上报积累后,达成一定特点时所被识别成的交互结果,如点击:按下并在较短时间内抬起。


如果使用 ArkUI 系统组件,系统会自动识别和响应这些组件上的手势,如按钮、列表,也可以在组件上绑定处理手势。一个组件上可绑定多个手势,这些手势可以由组件内置默认绑定,也可以由应用显式绑定。这些手势会在用户按下时,通过命中测试被收集上来,由系统统一管理,所有手势都会持续接收到输入事件,直到有一个手势满足条件,在这之后,就只有这一个手势可以继续接收和处理输入事件。


当用户的操作符合某个手势的特征时,系统会将其识别为该手势,这一过程称为手势识别。为了响应某一个手势,需在组件上添加对应的手势对象,以便系统可以收集并进行处理。


此处仅介绍 2 个单一手势,长按(LongPressGesture)、拖动(PanGesture)。

在播放器的案例中,通过长按控制播放器倍速播放,通过拖动调解播放音量。

三、界面制作

1、界面布局分析

视频播放主界面



播放速度设置界面



剩余的界面(窗口尺寸设置界面、多语言切换界面)与播放速度设置界面布局基本一致。

2、界面制作

2.1 多语言支持

在 DevEco Studio 中创建项目 BasicAVPlayer,根据 UI 界面的分析,抽取相关文字翻译成中文和英文版本,做国际化和本地化多语言支持。


PS:如果还不了解国际化和本地化,请参考文章《鸿蒙国际版快递 APP 自动识别地址》。


在 resources 目录下新建中文资源文件:zh_CN/element/string.json,内容如下:


{  "string": [    {      "name": "module_desc",      "value": "模块描述"    },    {      "name": "EntryAbility_desc",      "value": "description"    },    {      "name": "EntryAbility_label",      "value": "AVPlayer基础播控"    },    {      "name": "video_warn",      "value": "请检查网络是否连接或可用!"    },    {      "name": "video_speed_1_0X",      "value": "1.0X"    },    {      "name": "video_speed_1_25X",      "value": "1.25X"    },    {      "name": "video_speed_1_75X",      "value": "1.75X"    },    {      "name": "video_res_1",      "value": "test1.mp4"    },    {      "name": "video_res_2",      "value": "test2.mp4"    },    {      "name": "video_res_3",      "value": "network.mp4"    },    {      "name": "video_speed_2_0X",      "value": "2.0X"    },    {      "name": "dialog_cancel",      "value": "取消"    },    {      "name": "dialog_play_speed",      "value": "播放倍速"    },    {      "name": "playing",      "value": "当前播放"    },    {      "name": "reason_internet",      "value": "用于视频播放场景使用Internet网络"    },    {      "name": "reason_get_network_info",      "value": "用于视频播放场景获取网络信息"    },    {      "name": "video_scale_fit",      "value": "拉伸至与窗口等大"    },    {      "name": "video_scale_fit_crop",      "value": "缩放至最短边填满窗口"    },    {      "name": "dialog_play_scale",      "value": "窗口缩放模式"    },    {      "name": "local_video",      "value": "本地视频"    },    {      "name": "Chinese",      "value": "中文"    },    {      "name": "English",      "value": "英文"    },    {      "name": "language_switch",      "value": "语言切换"    }  ]}
复制代码


在 resources 目录下新建中文资源文件:en_US/element/string.json,内容如下:


{  "string": [    {      "name": "module_desc",      "value": "module description"    },    {      "name": "EntryAbility_desc",      "value": "description"    },    {      "name": "EntryAbility_label",      "value": "AVPlayerBasicControl"    },    {      "name": "video_res_1",      "value": "test1.mp4"    },    {      "name": "video_res_2",      "value": "test2.mp4"    },    {      "name": "video_res_3",      "value": "network.mp4"    },    {      "name": "video_speed_1_0X",      "value": "1.0X"    },    {      "name": "video_speed_1_25X",      "value": "1.25X"    },    {      "name": "video_speed_1_75X",      "value": "1.75X"    },    {      "name": "video_speed_2_0X",      "value": "2.0X"    },    {      "name": "video_warn",      "value": "Please check if the network is connected or available!"    },    {      "name": "dialog_cancel",      "value": "Cancel"    },    {      "name": "dialog_play_speed",      "value": "Playback speed"    },    {      "name": "playing",      "value": "playing"    },    {      "name": "reason_internet",      "value": "Used for accessing the Internet during video playback"    },    {      "name": "reason_get_network_info",      "value": "Used for retrieving network information during video playback"    },    {      "name": "video_scale_fit",      "value": "FIT"    },    {      "name": "video_scale_fit_crop",      "value": "FIT_CROP"    },    {      "name": "dialog_play_scale",      "value": "window scale mode"    },    {      "name": "local_video",      "value": "Local video"    },    {      "name": "Chinese",      "value": "Chinese"    },    {      "name": "English",      "value": "English"    },    {      "name": "language_switch",      "value": "Language switch"    }  ]}
复制代码


将 en_US/element/string.json 文件内容,拷贝到 resources/base/element/string.json 文件中,覆盖原来所有内容。


以上就完成了多语言资源的翻译和准备工作。


为了更好控制界面上组件的尺寸,将尺寸控制也抽取到配置文件中,修改 resources/base/element/float.json 文件,内容如下:


{  "float": [    {      "name": "size_zero_five",      "value": "0.5"    },    {      "name": "size_zero_six",      "value": "0.6"    },    {      "name": "size_zero",      "value": "0"    },    {      "name": "size_1",      "value": "1"    },    {      "name": "size_5",      "value": "5"    },    {      "name": "size_8",      "value": "8"    },    {      "name": "size_10",      "value": "10"    },    {      "name": "size_12",      "value": "12"    },    {      "name": "size_15",      "value": "15"    },    {      "name": "size_16",      "value": "16"    },    {      "name": "size_18",      "value": "18"    },    {      "name": "size_20",      "value": "20"    },    {      "name": "size_down_20",      "value": "-20"    },    {      "name": "size_down_80",      "value": "-80"    },    {      "name": "size_24",      "value": "24"    },    {      "name": "size_25",      "value": "25"    },    {      "name": "size_30",      "value": "30"    },    {      "name": "size_32",      "value": "32"    },    {      "name": "size_35",      "value": "35"    },    {      "name": "size_40",      "value": "40"    },    {      "name": "size_45",      "value": "45"    },    {      "name": "size_48",      "value": "48"    },    {      "name": "size_50",      "value": "50"    },    {      "name": "size_64",      "value": "64"    },    {      "name": "size_75",      "value": "75"    },    {      "name": "size_80",      "value": "-80"    },    {      "name": "size_210",      "value": "210"    },    {      "name": "size_254",      "value": "254"    }  ]}
复制代码


为了方便界面整体演示控制,把公用颜色抽取到配置文件中,修改 resources/base/element/color.json 文件,内容如下:


{  "color": [    {      "name": "start_window_background",      "value": "#FFFFFF"    },    {      "name": "slider_selected",      "value": "#007DFF"    },    {      "name": "speed_dialog",      "value": "#33bab4b4"    },    {      "name": "video_play",      "value": "#333333"    },    {      "name": "video_play_selected",      "value": "#5c5c5c"    },    {      "name": "back_button",      "value": "#4D505050"    },    {      "name": "scale_font_color",      "value": "#0A59F7"    }  ]}
复制代码

2.2 主界面制作

根据界面分析,制作主界面框架。考虑到代码可读性,可以将底部的播放控制条抽取为单独组件。主界面 Index.ets 文件主体框架内容如下:


@Entry@Componentstruct Index {  build() {    Stack() {      // 视频区域      Column() {        // todo:使用XComponent渲染AVPlayer      }      .align(Alignment.TopStart)      .margin({ top: $r('app.float.size_80') })      .id('Video')      .justifyContent(FlexAlign.Center)
// 播放器控制条 Column() { Blank() Column() { // todo:封装播放控制条组件 } .justifyContent(FlexAlign.Center) } .width('100%') .height('100%') } .backgroundColor(Color.Black) .height('100%') .width('100%') .padding({ top: '36vp', bottom: '28vp' }) }}
复制代码


将界面小图标素材拷贝至 resources/base/media 目录。


由于控制条播放过程中需要显示当前播放时长,需要对时间进行处理。在 src/main/ets 目录下,新建 common/utils 目录,其下新建 TimeUtils.ts 文件,封装处理日期格式化的函数 timeConvert,文件内容如下:


// 时间处理const TIME_ONE = 60000;const TIME_TWO = 1000;const TIME_THREE = 10;
export function timeConvert(time: number): string { let min: number = Math.floor(time / TIME_ONE); let second: string = ((time % TIME_ONE) / TIME_TWO).toFixed(0); return `${min}:${(+second < TIME_THREE ? '0' : '') + second}`;}
复制代码


在 src/main/ets 目录下新建 views 目录,用于存放自定义组件。接下来封装播放控制条组件的界面。创建 VideoBar.ets 文件:


import { timeConvert } from '../common/utils/TimeUtils';
@Componentexport struct VideoBar { @Link flag: boolean; // 播放/暂停 @Link currentTime: number; //当前播放时长 @Link durationTime: number; //视频时长 @StorageLink('speedName') speedName: Resource = $r('app.string.video_speed_1_0X'); //倍速 @StorageLink('isMuted') isMuted: boolean = false; //是否静音
build() { Column() { // 切换语言 Row() { Button() { Image($r('app.media.ic_video_translate')) .width($r('app.float.size_25')) .height($r('app.float.size_25')) } .type(ButtonType.Normal) .width($r('app.float.size_25')) .height($r('app.float.size_25')) .backgroundColor('rgba(0, 0, 0, 0)') .margin({ left: $r('app.float.size_5') }) .fontColor(Color.White) .onClick(() => { // todo:语言切换 }) } .width('100%') .padding({ left: $r('app.float.size_12'), right: $r('app.float.size_20') }) .justifyContent(FlexAlign.End)
// 播放控制条 Row() { // 播放/暂停按钮及播放时长 Row() { Image(this.flag ? $r('app.media.ic_video_play') : $r('app.media.ic_video_pause')) .id('play') .width($r('app.float.size_30')) .height($r('app.float.size_30')) .onClick(() => { // todo:播放或暂停 })
// 当前播放时长 Text(timeConvert(this.currentTime)) .fontColor(Color.White) .textAlign(TextAlign.End) .fontWeight(FontWeight.Regular) .margin({ left: $r('app.float.size_5') }) }
// 播放进度条 Row() { Slider({ value: this.currentTime, min: 0, max: this.durationTime, style: SliderStyle.OutSet }) .id('Slider') .blockColor(Color.White) .trackColor(Color.Gray) .selectedColor($r('app.color.slider_selected')) .showTips(false) .onChange((value: number, mode: SliderChangeMode) => { // todo:拖动控制播放进度 }) } .layoutWeight(1)
// 剩余时长、倍速控制按钮、静音、窗口尺寸按钮 Row() { // 视频总时长 Text(timeConvert(this.durationTime)) .fontColor(Color.White) .fontWeight(FontWeight.Regular)
// 视频倍速设置 Button(this.speedName, { type: ButtonType.Normal }) .border({ width: $r('app.float.size_1'), color: Color.White }) .width($r('app.float.size_64')) .height($r('app.float.size_30')) .fontSize($r('app.float.size_15')) .borderRadius($r('app.float.size_20')) .fontColor(Color.White) .backgroundColor('rgba(0, 0, 0, 0)') .opacity($r('app.float.size_1')) .padding({ left: $r('app.float.size_5'), right: $r('app.float.size_5') }) .margin({ left: $r('app.float.size_8') }) .id('Speed') .onClick(() => { // todo:打开倍速设置对话框 })
// 静音控制 Button() { Image(this.isMuted ? $r('app.media.ic_video_speaker_slash') : $r('app.media.ic_video_speaker')) .width($r('app.float.size_30')) .height($r('app.float.size_30')) } .type(ButtonType.Normal) .width($r('app.float.size_30')) .height($r('app.float.size_30')) .borderRadius($r('app.float.size_20')) .backgroundColor('rgba(0, 0, 0, 0)') .margin({ left: $r('app.float.size_5') }) .fontColor(Color.White) .onClick(() => { // todo:静音或取消静音 })
// 窗口尺寸设置 Button() { Image($r('app.media.ic_video_window_scale')) .width($r('app.float.size_25')) .height($r('app.float.size_25')) } .type(ButtonType.Normal) .width($r('app.float.size_25')) .height($r('app.float.size_25')) .backgroundColor('rgba(0, 0, 0, 0)') .margin({ left: $r('app.float.size_5') }) .fontColor(Color.White) .onClick(() => { // todo:打开窗口设置 }) } } .justifyContent(FlexAlign.Center) .padding({ left: $r('app.float.size_12'), right: $r('app.float.size_20') }) .width('100%') } }}
复制代码


在主界面中引入上述封装的播放控制组件,修改 Index.ets 文件:


import { VideoBar } from '../views/VideoBar';...
@State flag: boolean = true; // 暂停播放@State durationTime: number = 0; //视频时长@State currentTime: number = 0; //当前播放时长
...// todo:封装播放控制条组件 VideoBar({ flag: this.flag, currentTime: this.currentTime, durationTime: this.durationTime, }) .width('100%')...
复制代码


引入自定义的播放控制组件后,界面主体框架就搭建完成。至于中间区域的播放器,在其它静态界面完成后再来实现。

2.3 播放速度设置界面

编写自定义播放速度控制弹窗组件 SpeedDialog,在 src/main/ets/views 目录下新建文件:SpeedDialog.ets,内容如下:


// 倍速索引const ZERO = 0;const ONE = 1;const TWO = 2;const THREE = 3;
@CustomDialogexport struct SpeedDialog{ @State speedList: Resource[] = [$r('app.string.video_speed_1_0X'), $r('app.string.video_speed_1_25X'), $r('app.string.video_speed_1_75X'), $r('app.string.video_speed_2_0X')]; @Link @Watch('onSpeedSelectUpdate') speedSelect: number; // 当前选择速度索引
private controller: CustomDialogController; onSpeedSelectUpdate() { AppStorage.setOrCreate('speedName', this.speedList[this.speedSelect]); AppStorage.setOrCreate('speedIndex', this.speedSelect); }
build() { Column() { // 标题 Text($r('app.string.dialog_play_speed')) .fontSize($r('app.float.size_20')) .fontWeight(FontWeight.Bold) .width('90%') .fontColor(Color.Black) .textAlign(TextAlign.Center) .margin({ top: $r('app.float.size_20'), bottom: $r('app.float.size_12') })
// 速度列表 List() { ForEach(this.speedList, (item: Resource, index) => { ListItem() { Column() { Row() { Text(item) .fontSize($r('app.float.size_16')) .fontColor(Color.Black) .fontWeight(FontWeight.Medium) .textAlign(TextAlign.Center) Blank() Image(this.speedSelect === index ? $r('app.media.ic_radio_selected') : $r('app.media.ic_radio')) .width($r('app.float.size_24')) .height($r('app.float.size_24')) .objectFit(ImageFit.Contain) } .width('100%')
// 分割线 if (index != this.speedList.length - ONE) { Divider() .vertical(false) .strokeWidth(1) .margin({ top: $r('app.float.size_10') }) .color($r('app.color.speed_dialog')) .width('100%') } } .width('90%') } .width('100%') .height($r('app.float.size_48')) .onClick(() => { this.speedSelect = index; // todo:控制播放器速度 this.controller.close(); }) }, (item: Resource, index) => index + '_' + JSON.stringify(item)) } .width('100%') .height('192vp') .margin({ top: $r('app.float.size_12') })
// 取消按钮 Row() { Text($r('app.string.dialog_cancel')) .fontSize($r('app.float.size_16')) .fontColor('#0A59F7') .fontWeight(FontWeight.Medium) .layoutWeight(1) .textAlign(TextAlign.Center) .onClick(() => { this.controller.close(); }) } .alignItems(VerticalAlign.Center) .height($r('app.float.size_50')) .padding({ bottom: $r('app.float.size_5') }) .width('100%') } .alignItems(HorizontalAlign.Center) .width('100%') .margin({ left: $r('app.float.size_16'), right: $r('app.float.size_16') }) .borderRadius($r('app.float.size_24')) .backgroundColor(Color.White)
}}
复制代码


弹窗组件封装好后,在播放控制条组件 VideoBar.ets 中引入,为倍速设置按钮绑定事件,修改的控制条组件 VideoBar.ets 文件内容如下:


import { SpeedDialog } from './SpeedDialog';
...@State speedSelect: number = 0; // 倍速private dialogController: CustomDialogController = new CustomDialogController({ builder: SpeedDialog({ speedSelect: $speedSelect }), alignment: DialogAlignment.Center, offset: { dx: $r('app.float.size_zero'), dy: $r('app.float.size_down_20') } });@StorageLink('speedIndex') speedIndex: number = 0; //选择的倍速索引

...//倍速设置按钮.onClick(() => { // todo:打开倍速设置对话框 this.speedSelect = this.speedIndex; this.dialogController.open(); })...
复制代码


编写完成后,点击控制条的速度按钮,弹出速度设置框,选择速度后弹窗口关闭。这样速度弹出框界面编写完毕,后续还需要与控制器进行关联。

2.4 窗口尺寸设置界面

编写窗口尺寸设置弹窗组件 ScaleDialog,在 src/main/ets/views 目录下新建 ScaleDialog.ets 文件,内容如下:


// 选择索引const ZERO = 0;const ONE = 1;
@CustomDialogexport struct ScaleDialog { @State scaleList: Resource[] = [$r('app.string.video_scale_fit'), $r('app.string.video_scale_fit_crop')]; @Link @Watch('onWindowScaleSelectUpdate') windowScaleSelect: number; //当前选择项索引 private controller: CustomDialogController;
onWindowScaleSelectUpdate() { AppStorage.setOrCreate('videoScaleType', this.windowScaleSelect); }
build() { Column() { // 标题 Text($r('app.string.dialog_play_scale')) .fontSize($r('app.float.size_20')) .fontWeight(FontWeight.Bold) .width('90%') .fontColor(Color.Black) .textAlign(TextAlign.Center) .margin({ top: $r('app.float.size_20'), bottom: $r('app.float.size_12') })
// 选择项 List() { ForEach(this.scaleList, (item: Resource, index) => { ListItem() { Column() { // 选项项 Row() { Text(item) .fontSize($r('app.float.size_16')) .fontColor(Color.Black) .fontWeight(FontWeight.Medium) .textAlign(TextAlign.Center) Blank() Image(this.windowScaleSelect === index ? $r('app.media.ic_radio_selected') : $r('app.media.ic_radio')) .width($r('app.float.size_24')) .height($r('app.float.size_24')) .objectFit(ImageFit.Contain) } .width('100%')
// 分割线 if (index != this.scaleList.length - ONE) { Divider() .vertical(false) .strokeWidth(1) .margin({ top: $r('app.float.size_10') }) .color($r('app.color.speed_dialog')) .width('100%') } } .width('90%') } .width('100%') .height($r('app.float.size_48')) .onClick(() => { this.windowScaleSelect = index; // todo:控制播放器窗口尺寸 this.controller.close(); }) }) } .width('100%') .height('192vp') .margin({ top: $r('app.float.size_12') })
// 取消按钮 Row() { Text($r('app.string.dialog_cancel')) .fontSize($r('app.float.size_16')) .fontColor($r('app.color.scale_font_color')) .fontWeight(FontWeight.Medium) .layoutWeight(1) .textAlign(TextAlign.Center) .onClick(() => { this.controller.close() }) } .alignItems(VerticalAlign.Center) .height($r('app.float.size_50')) .padding({ bottom: $r('app.float.size_5') }) .width('100%') } .alignItems(HorizontalAlign.Center) .width('100%') .margin({ left: $r('app.float.size_16'), right: $r('app.float.size_16') }) .borderRadius($r('app.float.size_24')) .backgroundColor(Color.White)
}}
复制代码


窗口设置组件封装好后,在播放控制条组件 VideoBar.ets 中引入,为窗口尺寸设置按钮绑定事件,修改的控制条组件 VideoBar.ets 文件内容如下:


import { ScaleDialog } from './ScaleDialog';import { media } from '@kit.MediaKit';
@State windowScaleSelect: number = 0 //窗口尺寸@StorageLink('videoScaleType') videoScaleType: number = media.VideoScaleType.VIDEO_SCALE_TYPE_FIT;private scaleDialogController: CustomDialogController = new CustomDialogController({ builder: ScaleDialog({ windowScaleSelect: $windowScaleSelect }), alignment: DialogAlignment.Center, offset: { dx: $r('app.float.size_zero'), dy: $r('app.float.size_down_20') } });
...//窗口设置按钮.onClick(() => { // todo:打开窗口设置 this.windowScaleSelect = this.videoScaleType; this.scaleDialogController.open(); })...

复制代码


编写完成后,点击控制条的窗口设置按钮,弹出窗口设置框,选择窗口尺寸后弹窗口关闭。这样窗口弹出框界面编写完毕,后续还需要与控制器进行关联控制播放器窗口尺寸。

2.5 多语言切换界面

编写多语言切换弹窗组件 LanguageDialog,在 src/main/ets/views 目录下新建 LanguageDialog.ets 文件,内容如下:


// 选择项索引const ONE = 1;
@CustomDialogexport struct LanguageDialog { private controller: CustomDialogController; @Link @Watch('onLanguageSelectUpdate') languageSelect: number; //当前选择项索引 @State languageList: Resource[] = [$r('app.string.Chinese'), $r('app.string.English')];
onLanguageSelectUpdate() { AppStorage.setOrCreate('currentLanguageType', this.languageSelect); }
build() { Column() { // 标题 Text($r('app.string.language_switch')) .fontSize($r('app.float.size_20')) .fontWeight(FontWeight.Bold) .width('90%') .fontColor(Color.Black) .textAlign(TextAlign.Center) .margin({ top: $r('app.float.size_20'), bottom: $r('app.float.size_12') })
// 语言选项列表 List() { ForEach(this.languageList, (item: Resource, index) => { ListItem() { Column() { Row() { Text(item) .fontSize($r('app.float.size_16')) .fontColor(Color.Black) .fontWeight(FontWeight.Medium) .textAlign(TextAlign.Center) Blank() Image(this.languageSelect === index ? $r('app.media.ic_radio_selected') : $r('app.media.ic_radio')) .width($r('app.float.size_24')) .height($r('app.float.size_24')) .objectFit(ImageFit.Contain) } .width('100%')
if (index != this.languageList.length - ONE) { Divider() .vertical(false) .strokeWidth(1) .margin({ top: $r('app.float.size_10') }) .color($r('app.color.speed_dialog')) .width('100%') } } .width('90%') } .width('100%') .height($r('app.float.size_48')) .onClick(() => { this.languageSelect = index; // todo:切换语言 this.controller.close(); }) }, (item: Resource, index) => index + '_' + JSON.stringify(item)) } .width('100%') .height('192vp') .margin({ top: $r('app.float.size_12') })
// 取消按钮 Row() { Text($r('app.string.dialog_cancel')) .fontSize($r('app.float.size_16')) .fontColor('#0A59F7') .fontWeight(FontWeight.Medium) .layoutWeight(1) .textAlign(TextAlign.Center) .onClick(() => { this.controller.close(); }) } .alignItems(VerticalAlign.Center) .height($r('app.float.size_50')) .padding({ bottom: $r('app.float.size_5') }) .width('100%')
} .alignItems(HorizontalAlign.Center) .width('100%') .margin({ left: $r('app.float.size_16'), right: $r('app.float.size_16') }) .borderRadius($r('app.float.size_24')) .backgroundColor(Color.White)
}}
复制代码


多语言设置组件封装好后,在播放控制条组件 VideoBar.ets 中引入,为语言切换按钮绑定事件,修改的控制条组件 VideoBar.ets 文件内容如下:


import { LanguageDialog } from './LanguageDialog';
...@State languageSelect: number = 0@StorageLink('currentLanguageType') currentLanguageType: number = 0; //当前选择语言private languageDialogController: CustomDialogController = new CustomDialogController({ builder: LanguageDialog({ languageSelect: $languageSelect }), alignment: DialogAlignment.Center, offset: { dx: $r('app.float.size_zero'), dy: $r('app.float.size_down_20') } });

//语言切换按钮.onClick(() => { // todo:语言切换 this.languageSelect = this.currentLanguageType; this.languageDialogController.open(); })
复制代码


编写完成后,点击语言切换按钮,弹出语言选择框,选择语言后弹窗口关闭。这样语言切换弹出框界面编写完毕。

四、功能实现

界面编写完成后,接下来依次实现具体功能。考虑将播放器相关的控制封装到一个类 AvPlayerController 中,需要进行播放器控制的地方,直接传递此类,然后调用对应的方法即可完成播放器的控制。

1、视频渲染

将通用常量进行封装,在 src/main/ets/common 目录下新建 constants 目录,新建 CommonConstants.ets 文件, 在其中 定义枚举类型 VideoDataType、CommonConstants、AVPlayerState


import { emitter } from '@kit.BasicServicesKit';export enum VideoDataType {  RAW_FILE = 1,  RAW_M3U8_FILE = 2,  URL = 3,  RAW_MP4_FILE = 4}
export class CommonConstants { static readonly AVPLAYER_IDLE: emitter.InnerEvent = { eventId: 1, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_INITIALIZED: emitter.InnerEvent = { eventId: 2, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_PREPARED: emitter.InnerEvent = { eventId: 3, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_PLAYING: emitter.InnerEvent = { eventId: 4, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_COMPLETED: emitter.InnerEvent = { eventId: 5, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_PAUSED: emitter.InnerEvent = { eventId: 6, priority: emitter.EventPriority.HIGH }; static readonly AVPLAYER_STOPPED: emitter.InnerEvent = { eventId: 7, priority: emitter.EventPriority.HIGH }; static readonly innerEventFalse: emitter.InnerEvent = { eventId: 11, priority: emitter.EventPriority.HIGH }; static readonly innerEventTrue: emitter.InnerEvent = { eventId: 12, priority: emitter.EventPriority.HIGH }; static readonly innerEventWH: emitter.InnerEvent = { eventId: 13, priority: emitter.EventPriority.HIGH }; static readonly SLIDER_PROGRESS_MIN: number = 0 static readonly SLIDER_PROGRESS_STEP: number = 0.1 /** * Full size. */ static readonly FULL_SIZE: string = '100%'; /** * Seek hour unit */ static readonly HOUR_UNIT: number = 60; /** * Second to Millisecond */ static readonly SECOND_TO_MS: number = 1000; /** * Time const number */ static readonly TIME_CONST_TEN: number = 10; /** * Hilog Domain. */ static readonly LOG_DOMAIN = 0x0000;}
export enum AVPlayerState { /** * Idle state of avPlayer. */ IDLE = 'idle',
/** * Initialized state of avPlayer. */ INITIALIZED = 'initialized',
/** * Prepared state of avPlayer. */ PREPARED = 'prepared',
/** * Playing state of avPlayer. */ PLAYING = 'playing',
/** * Pause state of avPlayer. */ PAUSED = 'paused',
/** * Completed state of avPlayer. */ COMPLETED = 'completed',
/** * Stopped state of avPlayer. */ STOPPED = 'stopped',
/** * Release state of avPlayer. */ RELEASED = 'released',
/** * Error state of avPlayer. */ ERROR = 'error', UNDEFINED = 'undefined'}

复制代码


定义视频接口 VideoData,在 src/main/ets 目录下新建 model 目录,新建 VideoData.ets 文件


import { VideoDataType } from '../common/constants/CommonConstants';export interface VideoData {  type: VideoDataType;  videoSrc: string;  name?: string | Resource;  description?: ResourceStr;  head?: Resource;  caption?: string;  index?: number;  seekTime?:number;  isMuted?:boolean;}

复制代码


封装播放器控制功能,相关功能封装到 AvPlayerController 类,在 src/main/ets 目录下新建 controller 目录,新建 AvPlayerController.ets 文件


import { VideoData } from '../model/VideoData';import { media } from '@kit.MediaKit';import { audio } from '@kit.AudioKit';import { common } from '@kit.AbilityKit';import { hilog } from '@kit.PerformanceAnalysisKit';import { CommonConstants, VideoDataType } from '../common/constants/CommonConstants';import { BusinessError, emitter } from '@kit.BasicServicesKit';
const TAG = '[AvPlayerController]';const CASE_ZERO = 0;const CASE_ONE = 1;const CASE_TWO = 2;const CASE_THREE = 3;
@Observedexport class AvPlayerController { @Track surfaceID: string = ''; @Track isPlaying: boolean = false; @Track duration: number = 0; @Track currentTime: number = 0; @Track isReady: boolean = false; @Track durationTime: number = 0; private curSource?: VideoData; private avPlayer?: media.AVPlayer; private context: common.UIAbilityContext | undefined = AppStorage.get('context'); private seekTime?: number; private isMuted: boolean | undefined = undefined; private index: number = 0; private windowScaleSelect: number = 0; private speedSelect: number = 0;
// 创建AVPlayer实例 public async initAVPlayer(source: VideoData, surfaceId: string, avPlayer?: media.AVPlayer) { if (!this.context) { hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer failed context not set`); return } this.curSource = source; if (source.seekTime) { this.seekTime = source.seekTime; } if (source.isMuted) { this.isMuted = source.isMuted; } if (source.index) { this.index = source.index; } hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer == this.curSource : ${JSON.stringify(this.curSource)}`); if (!this.curSource) { return; } hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer == initCamera surfaceId == ${surfaceId}`); this.surfaceID = surfaceId; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer == this.surfaceID surfaceId == ${this.surfaceID}`);
try { hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'initPlayer videoPlay avPlayerDemo'); // Creates the avPlayer instance object. this.avPlayer = avPlayer ? avPlayer : await media.createAVPlayer() // Creates a callback function for state machine changes. this.setAVPlayerCallback(); hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'initPlayer videoPlay setAVPlayerCallback');
if (!this.context) { hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer failed context not set`); return } switch (this.curSource.type) { case VideoDataType.RAW_FILE: let fileDescriptor = await this.context.resourceManager.getRawFd(this.curSource.videoSrc); this.avPlayer.fdSrc = fileDescriptor; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer videoPlay src = ${JSON.stringify(this.avPlayer.fdSrc)}`); break;
case VideoDataType.URL: this.avPlayer.url = this.curSource.videoSrc; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer videoPlay url = ${JSON.stringify(this.avPlayer.url)}`); break;
case VideoDataType.RAW_M3U8_FILE: let m3u8Fd = await this.context.resourceManager.getRawFd(this.curSource.videoSrc); let fdUrl = 'fd://' + m3u8Fd.fd + '?offset=' + m3u8Fd.offset + '&size=' + m3u8Fd.length; let mediaSource = media.createMediaSourceWithUrl(fdUrl); mediaSource.setMimeType(media.AVMimeTypes.APPLICATION_M3U8); // let playbackStrategy: media.PlaybackStrategy = { preferredBufferDuration: 20, showFirstFrameOnPrepare: true }; let playbackStrategy: media.PlaybackStrategy = { preferredBufferDuration: 20}; await this.avPlayer.setMediaSource(mediaSource, playbackStrategy); hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer videoPlay fdUrl = ${JSON.stringify(fdUrl)}`); break; case VideoDataType.RAW_MP4_FILE: let mp4Fd = await this.context.resourceManager.getRawFd(this.curSource.videoSrc); let mp4FdUrl = 'fd://' + mp4Fd.fd; this.avPlayer.url = mp4FdUrl; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `initPlayer videoPlay fdUrl = ${JSON.stringify(mp4FdUrl)}`); break; default: break; } // [Start AddCaption] if (this.curSource.caption) { let fileDescriptorSub = await this.context.resourceManager.getRawFd(this.curSource.caption); this.avPlayer.addSubtitleFromFd(fileDescriptorSub.fd, fileDescriptorSub.offset, fileDescriptorSub.length); hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'initPlayer videoPlay addSubtitleFromFd'); } // [End AddCaption] } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `initPlayer initPlayer, code is ${err.code}, message is ${err.message}`); } }
private setAVPlayerCallback() { if (!this.avPlayer) { return; } this.avPlayer!.on('error', (err: BusinessError) => { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `AVPlayer error, code is ${err.code}, message is ${err.message}`); this.avPlayer!.reset(); }); // Listening function for reporting time this.avPlayer!.on('startRenderFrame', () => { hilog.info(CommonConstants.LOG_DOMAIN, TAG, `AVPlayer start render frame`); AppStorage.setOrCreate('StartRender', true); }); this.avPlayer!.on('durationUpdate', (time: number) => { this.duration = time; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `AVPlayer duration update: ${time}`); AppStorage.setOrCreate('DurationTime', time); }); this.avPlayer.on('timeUpdate', (time: number) => { this.currentTime = time; AppStorage.setOrCreate('CurrentTime', time); hilog.info(CommonConstants.LOG_DOMAIN, TAG, `setAVPlayerCallback timeUpdate success, and new time is = ${this.currentTime}`); });
// The error callback function is triggered when an error occurs during avPlayer operations, // at which point the reset interface is called to initiate the reset process this.avPlayer.on('error', (err: BusinessError) => { if (!this.avPlayer) { return; } hilog.error(CommonConstants.LOG_DOMAIN, TAG, `Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`); this.avPlayer.reset(); // resets the resources and triggers the idle state }) // todo:字幕播放 this.setStateChangeCallback(); }
private setStateChangeCallback() { if (!this.avPlayer) { return; } // [Start loop_playback] /** * Loop playback */ // Callback function for state machine changes this.avPlayer.on('stateChange', async (state) => { if (!this.avPlayer) { return; } switch (state) { // DocsDot // [StartExclude state] case 'idle': // This state machine is triggered after the reset interface is successfully invoked. hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state idle called.'); break; case 'initialized': // This status is reported after the playback source is set on the AVPlayer. hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state initialized called.'); // Set the display screen. This parameter is not required when the resource to be played is audio-only. this.avPlayer.surfaceId = this.surfaceID; hilog.info(CommonConstants.LOG_DOMAIN, TAG, `setAVPlayerCallback this.avPlayer.surfaceId = ${this.avPlayer.surfaceId}`); this.avPlayer.prepare(); break; // [EndExclude state] // DocsDot case 'prepared': // This state machine is reported after the prepare interface is successfully invoked. hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state prepared called.'); this.isReady = true; this.avPlayer.loop = true // DocsDot // [StartExclude prepared] this.durationTime = this.avPlayer.duration; this.currentTime = this.avPlayer.currentTime; this.avPlayer.audioInterruptMode = audio.InterruptMode.SHARE_MODE; if (this.seekTime) { this.avPlayer!.seek(this.seekTime!, media.SeekMode.SEEK_CLOSEST); } let eventData: emitter.EventData = { data: { 'percent': this.avPlayer.width / this.avPlayer.height } }; emitter.emit(CommonConstants.AVPLAYER_PREPARED, eventData); if (this.isMuted) { await this.avPlayer!.setMediaMuted(media.MediaType.MEDIA_TYPE_AUD, this.isMuted!) }
this.setWindowScale();
if (this.index === 0) { this.avPlayer.play(); // Invoke the playback interface to start playback. }
this.setVideoSpeed(); // [EndExclude prepared] // DocsDot break; // DocsDot // [StartExclude other_state] case 'playing': // After the play interface is successfully invoked, the state machine is reported. hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state playing called.'); this.isPlaying = true; let eventDataTrue: emitter.EventData = { data: { 'flag': true } }; let innerEventTrue: emitter.InnerEvent = { eventId: 2, priority: emitter.EventPriority.HIGH }; emitter.emit(innerEventTrue, eventDataTrue); break; case 'completed': // This state machine is triggered to report when the playback ends. hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state completed called.'); this.currentTime = 0; let eventDataFalse: emitter.EventData = { data: { 'flag': false } }; let innerEvent: emitter.InnerEvent = { eventId: 1, priority: emitter.EventPriority.HIGH }; emitter.emit(innerEvent, eventDataFalse); break; default: hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'setAVPlayerCallback AVPlayer state unknown called.'); break; // [EndExclude other_state] // DocsDot } }); // [End loop_playback] }
private setWindowScale() { switch (this.windowScaleSelect) { case CASE_ZERO: this.videoScaleFit(); break; case CASE_ONE: this.videoScaleFitCrop(); break; default: break; } }
private setVideoSpeed() { switch (this.speedSelect) { case CASE_ZERO: this.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X); break; case CASE_ONE: this.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_25_X); break; case CASE_TWO: this.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_75_X); break; case CASE_THREE: this.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X); break; default: break; } }
videoScaleFit(): void { if (this.avPlayer) { try { this.avPlayer.videoScaleType = media.VideoScaleType.VIDEO_SCALE_TYPE_FIT hilog.info(CommonConstants.LOG_DOMAIN, TAG, `videoScaleType_0`); } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `videoScaleType_0 failed, code is ${err.code}, message is ${err.message}`); } } }
videoScaleFitCrop(): void { if (this.avPlayer) { try { this.avPlayer.videoScaleType = media.VideoScaleType.VIDEO_SCALE_TYPE_FIT_CROP hilog.info(CommonConstants.LOG_DOMAIN, TAG, `videoScaleType_1`); } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `videoScaleType_1 failed, code is ${err.code}, message is ${err.message}`); } } }
videoSpeed(speed: number): void { if (this.avPlayer) { try { this.avPlayer.setSpeed(speed); hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'videoSpeed'); } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `videoSpeed failed, code is ${err.code}, message is ${err.message}`); } } } }
复制代码


接下来在主界面渲染视频,修改 Index.ets 文件


import { VideoDataType } from '../common/constants/CommonConstants';import { VideoData } from '../model/VideoData';import { AvPlayerController } from '../controller/AvPlayerController';
...@State avPlayerController: AvPlayerController = new AvPlayerController()@State surfaceW: number = 0;@State surfaceH: number = 0;@State isCalcWHFinished: boolean = false;@StorageLink('videoScaleType') videoScaleType: number = 0;private xComponentController: XComponentController = new XComponentController();private surfaceId: string = '';
@Builder CoverXComponent() { XComponent({ // Loading the video container id: 'xComponent', type: XComponentType.SURFACE, controller: this.xComponentController }) .onLoad(() => { this.surfaceId = this.xComponentController.getXComponentSurfaceId(); let source: VideoData = { type: VideoDataType.RAW_FILE, videoSrc: 'test1.mp4', name: $r('app.string.local_video'), description: '', caption: 'captions.srt', index: 0 } this.avPlayerController.initAVPlayer(source, this.surfaceId); }) .height(this.videoScaleType === 0 ? (this.isCalcWHFinished ? `${this.surfaceH}px` : '100%') : null) .width(this.videoScaleType === 0 ? (this.isCalcWHFinished ? `${this.surfaceW}px` : '100%') : null) }
...// 视频区域Column() { // todo:使用XComponent渲染AVPlayer this.CoverXComponent()}


复制代码


将视频素材 test1.mp4 拷贝到 resources/rawfile 目录,运行项目,发现没有渲染出视频。


改造 EntryAbility.ets 文件,在 onCreate 函数中保存 contenxt,这样 AVPlayer 才能渲染到界面上。EntryAbility.ets 文件:


onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {   AppStorage.setOrCreate('context', this.context);   ...  }
复制代码


重新运行项目,可以看到视频已成功渲染。


虽然成功渲染,但是视频占满整个屏幕,且底部有白边。为例更好的体验,将其改为沉浸式。改造 onWindowStageCreate 方法,修改 EntryAbility.ets 文件


import { emitter } from '@kit.BasicServicesKit';import { CommonConstants } from '../common/constants/CommonConstants';const TAG = '[EntryAbility]';...
onWindowStageCreate(windowStage: window.WindowStage): void {... try { windowStage.getMainWindow().then((win: window.Window) => { win.setWindowKeepScreenOn(true); win.setWindowSystemBarProperties({ statusBarColor: '#000000', statusBarContentColor: '#FFFFFF' }); win.setWindowLayoutFullScreen(true); win.on('windowSizeChange', (newSize: window.Size) => { let eventWHData: emitter.EventData = { data: { 'width': newSize.width, 'height': newSize.height } }; emitter.emit(CommonConstants.innerEventWH, eventWHData); }); }); } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `getMainWindow failed, code is ${err.code}, message is ${err.message}`); }...}
复制代码


运行后,顶部和底部白边没了,实现了沉浸式效果。


视频目前占满整个屏幕,接下来设置播放器组件尺寸。封装 GlobalContext 类,在 ets/common/utils 目录下新建 GlobalContext.ets 文件


export class GlobalContext {  private constructor() {  }
private static instance: GlobalContext; private _objects = new Map<string, Object>();
public static getContext(): GlobalContext { if (!GlobalContext.instance) { GlobalContext.instance = new GlobalContext(); } return GlobalContext.instance; }
getObject(value: string): Object | undefined { return this._objects.get(value); }
setObject(key: string, objectClass: Object): void { this._objects.set(key, objectClass); }}
复制代码


在 Index.ets 中设置播放器初始宽高,在 aboutToAppear 中触发事件监听播放器已就绪状态(CommonConstants.AVPLAYER_PREPARED),设置 isCalcWHFinished 为 true,修改 Index.ets


import { hilog } from '@kit.PerformanceAnalysisKit';import { display } from '@kit.ArkUI';import { VideoDataType, CommonConstants } from '../common/constants/CommonConstants';import { GlobalContext } from '../common/utils/GlobalContext';import { emitter } from '@kit.BasicServicesKit';
const SURFACE_WIDTH = 0.9; // Surface width ratioconst SURFACE_HEIGHT = 1.78; // Surface height ratioconst TAG = '[Index]';const PROPORTION = 0.99; // Screen Percentageconst SET_INTERVAL = 100;
...@State windowWidth: number = 300;@State windowHeight: number = 300;@State percent: number = 0;
... aboutToAppear() { try { this.windowWidth = display.getDefaultDisplaySync().width; this.windowHeight = display.getDefaultDisplaySync().height; } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `getDefaultDisplaySync failed, code is ${err.code}, message is ${err.message}`); } this.surfaceW = (GlobalContext.getContext().getObject('windowWidth') as number) * SURFACE_WIDTH; this.surfaceH = this.surfaceW / SURFACE_HEIGHT; this.flag = true; AppStorage.setOrCreate('avPlayerController', this.avPlayerController); emitter.on(CommonConstants.AVPLAYER_PREPARED, (res) => { if (res.data) { this.percent = res.data.percent; this.setVideoWH(); this.isCalcWHFinished = true; this.durationTime = this.avPlayerController.durationTime; setInterval(() => { // Update the current time. if (!this.isSwiping) { this.currentTime = this.avPlayerController.currentTime; } }, SET_INTERVAL); } }); }
aboutToDisappear() { this.avPlayerController.videoRelease(); emitter.off(CommonConstants.innerEventFalse.eventId); }
setVideoWH(): void { if (this.percent >= 1) { // Horizontal video this.surfaceW = Math.round(this.windowWidth * PROPORTION); this.surfaceH = Math.round(this.surfaceW / this.percent); } else { // Vertical video this.surfaceH = Math.round(this.windowHeight * PROPORTION); this.surfaceW = Math.round(this.surfaceH * this.percent); } }...
复制代码


这样就实现了播放器居中显示,并且自动播放。播放时间也跟着播放进度变化。


打印日志时将打印的标识提到外部变量,方便统一设置,依次修改 EntryAbility.ets 文件中日志的打印函数


const TAG = '[EntryAbility]';
... onBackground(): void { // Ability has back to background // hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); hilog.info(CommonConstants.LOG_DOMAIN, TAG, '%{public}s', 'Ability onBackground'); }
复制代码

2、改变窗口尺寸

在窗口尺寸界面,选择尺寸后,为对应的选择绑定事件,调用 AvPlayerController 的方法实现窗口改变,ScaleDialog.ets 文件修改:


import { AvPlayerController } from '../controller/AvPlayerController';...@StorageLink('avPlayerController') avPlayerController: AvPlayerController = new AvPlayerController();
//ListItem选这项 // todo:控制播放器窗口尺寸 switch (this.windowScaleSelect) { case ZERO: this.avPlayerController.videoScaleFit(); break; case ONE: this.avPlayerController.videoScaleFitCrop(); break; default: break; }
复制代码


修改 VideoBar 接收参数,修改 VideoBar.ets 文件


import { AvPlayerController } from '../controller/AvPlayerController';...  @Link isSwiping: boolean;  @Link avPlayerController: AvPlayerController;  @Link XComponentFlag: boolean;
复制代码


修改 Index.ets,在创建 VideoBar 时传参数


...  @State isSwiping: boolean = false;  @State XComponentFlag: boolean = false;...// todo:封装播放控制条组件          VideoBar({            flag: this.flag,            avPlayerController: this.avPlayerController,            currentTime: this.currentTime,            durationTime: this.durationTime,            isSwiping: this.isSwiping,            XComponentFlag: this.XComponentFlag          })
复制代码


选择不同的窗口尺寸后,界面随之变化。

3、播放/暂停

AvPlayerController 类添加视频播放和暂停功能,修改 AvPlayerController.ets 文件,添加 videoPlay 和 videoPause 两个方法:


 ... videoPlay(): void {    if (this.avPlayer) {      try {        this.avPlayer.play();        this.isPlaying = true;      } catch (err) {        hilog.error(CommonConstants.LOG_DOMAIN, TAG,          `videoPlay failed, code is ${err.code}, message is ${err.message}`);      }    }  }
videoPause(): void { if (this.avPlayer) { try { this.avPlayer.pause(); this.isPlaying = false; hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'videoPause'); } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `videoPause failed, code is ${err.code}, message is ${err.message}`); } } }...
复制代码


修改 VideoBar.ets,为播放和暂停按钮绑定事件,在事件处理函数中调用 AvPlayerController 封装的播放和暂停方法,即可实现播放和暂停功能。


//播放/暂停按钮.onClick(() => {              // todo:播放或暂停              this.flag ? this.avPlayerController.videoPause() : this.avPlayerController.videoPlay();              this.flag = !this.flag;            })
复制代码

4、静音播放

AvPlayerController 类添加视频静音和取消静音功能,修改 AvPlayerController.ets 文件,添加 videoMuted 方法:


async videoMuted(isMuted: boolean): Promise<void> {    if (this.avPlayer) {      try {        this.isMuted = isMuted;        await this.avPlayer!.setMediaMuted(media.MediaType.MEDIA_TYPE_AUD, isMuted)        hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'videoMuted');      } catch (err) {        hilog.error(CommonConstants.LOG_DOMAIN, TAG,          `videoMuted failed, code is ${err.code}, message is ${err.message}`);      }    }  }
复制代码


在播控组件中,为静音按钮绑定事件。修改 VideoBar.ets 文件


//声音图标.onClick(() => {           // todo:静音或取消静音            this.isMuted = !this.isMuted;            this.avPlayerController.videoMuted(this.isMuted)     })
复制代码


点击就可以静音功能,再次点击恢复声音。

5、倍速播放

之前 AvPlayerController 已封装了速度控制的方法 videoSpeed,只需要传入参数调用即可。


在播放速度弹出框的每一项点击事件中,调用 videoSpeed 方法,传入选择的倍速即可。修改 SpeedDialog.ets 文件:


import { media } from '@kit.MediaKit';import { AvPlayerController } from '../controller/AvPlayerController';

//倍速列表项的点击事件 // todo:控制播放器速度 switch (this.speedSelect) { case ZERO: this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X); break; case ONE: this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_25_X); break; case TWO: this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_75_X); break; case THREE: this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X); break; default: break; }
复制代码


这样就实现了倍速播放。

6、显示字幕

案例视频 test1.mp4 是已经通过剪辑软件,添加了字幕的。针对原始视频,也可以通过编辑单独的字幕文件进行字幕播放。


准备中英文字幕文件:captions.srt、en_captions.srt,存放到 resources/rawfile 目录下。


在播放器控制类中,添加播放器标题(字幕)改变的监听回调函数,修改 AvPlayerController.ets 文件:


...@Track currentCaption: string = '';...
private setAVPlayerCallback() { ... // todo:字幕播放 this.subtitleUpdateFunction(); ... }
subtitleUpdateFunction(): void { try { if (this.avPlayer) { // [Start RegisterCaptionCallBack] this.avPlayer.on('subtitleUpdate', (info: media.SubtitleInfo) => { if (info) { let text = (!info.text) ? '' : info.text; let startTime = (!info.startTime) ? 0 : info.startTime; let duration = (!info.duration) ? 0 : info.duration; this.currentCaption = text; //update current caption content hilog.info(CommonConstants.LOG_DOMAIN, TAG, `subtitleUpdate info: text:${text}, startTime:${startTime}, duration:${duration}`); } else { this.currentCaption = ''; hilog.error(CommonConstants.LOG_DOMAIN, TAG, 'subtitleUpdate info is null'); } }); // [End RegisterCaptionCallBack] } } catch (err) { hilog.error(CommonConstants.LOG_DOMAIN, TAG, `subtitleUpdateFunction failed, code is ${err.code}, message is ${err.message}`); } }
复制代码


在主界面播放器下显示字幕。修改 Index.ets 文件


...// 视频区域      Column() {        // 字幕        Stack({ alignContent: Alignment.Center }) {          Text(this.avPlayerController.currentCaption)            .fontColor(Color.White)            .fontSize($r('app.float.size_20'))            .fontFamily('Sans')        }        .width('100%')        .position({ x: $r('app.float.size_zero'), y: $r('app.float.size_210') })        .zIndex(1)
// todo:使用XComponent渲染AVPlayer this.CoverXComponent() }
复制代码


视频播放时,就会自动显示字幕。

7、多语言切换

播放器控制类 AvPlayerController 添加 languageChange 函数,根据选择的语言切换中英文字幕文件。修改 AvPlayerController.ets 文件


async languageChange(languageSelect: number = 0): Promise<void> {   if (this.avPlayer) {     try {       if (this.curSource && this.curSource.caption) {         this.curSource.caption = languageSelect === 0 ? 'captions.srt' : 'en_captions.srt'         this.curSource.seekTime = this.avPlayer.currentTime;         await this.avPlayer.reset();         this.initAVPlayer(this.curSource, this.surfaceID, this.avPlayer);         hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'language change');       }     } catch (err) {       hilog.error(CommonConstants.LOG_DOMAIN, TAG,         `languageChange failed, code is ${err.code}, message is ${err.message}`);     }   } }
复制代码


在语言选择界面中,绑定切换语言事件,调用播放器改变语言函数 languageChange 改变字幕,使用 118 改变软件界面语言,修改 LanguageDialog.ets 文件:


import { AvPlayerController } from '../controller/AvPlayerController';import { i18n } from '@kit.LocalizationKit';
...@StorageLink('avPlayerController') avPlayerController: AvPlayerController = new AvPlayerController();
...//切换语言按钮事件 // todo:切换语言 this.avPlayerController.languageChange(this.languageSelect); if(index===1){ i18n.System.setAppPreferredLanguage("en"); }else { i18n.System.setAppPreferredLanguage("zh"); }
复制代码


这样就实现了界面和字幕的多语言切换。

7、拖动播放

播放器控制类添加根据时间定位播放方法 videoSeek,修改 AvPlayerController.ets


videoSeek(seekTime: number): void {    if (this.avPlayer) {      try {        this.avPlayer.seek(seekTime, media.SeekMode.SEEK_CLOSEST);        hilog.info(CommonConstants.LOG_DOMAIN, TAG, `videoSeek== ${seekTime}`);      } catch (err) {        hilog.error(CommonConstants.LOG_DOMAIN, TAG,          `videoSeek failed, code is ${err.code}, message is ${err.message}`);      }    }  }
复制代码


播放器控件中的播放进度滑块绑定事件,实现拖动播放,修改 VideBar.ets


//播放进度控制滑块.onChange((value: number, mode: SliderChangeMode) => {              // todo:拖动控制播放进度              if (mode === SliderChangeMode.Begin) {                this.isSwiping = true;                this.avPlayerController.videoPause();              }              this.avPlayerController.videoSeek(value);              this.currentTime = value;              if (mode === SliderChangeMode.End) {                this.isSwiping = false;                this.flag = true;                this.avPlayerController.videoPlay();              }            })
复制代码


这样,就实现了拖动播放功能。


为了更好的体验,改造界面,在拖动滑块时,界面显示拖动的时间。修改 Index.ets


import { timeConvert } from '../common/utils/TimeUtils';...
// 遮罩 Text() .height(`${this.surfaceH}px`) .width(`${this.surfaceW}px`) .margin({ top: $r('app.float.size_80') }) .backgroundColor(Color.Black) .opacity($r('app.float.size_zero_five')) .visibility(this.isSwiping ? Visibility.Visible : Visibility.Hidden)
Row() { Text(timeConvert(this.currentTime)) .fontSize($r('app.float.size_24')) .opacity($r('app.float.size_1')) .fontColor($r('app.color.slider_selected')) Text('/' + timeConvert(this.durationTime)) .fontSize($r('app.float.size_24')) .opacity($r('app.float.size_1')) .fontColor(Color.White) } .margin({ top: $r('app.float.size_80') }) .visibility(this.isSwiping ? Visibility.Visible : Visibility.Hidden)



复制代码


这样就实现了,在拖动滑块时,在界面上提示当前拖动的时间。

8、音量控制

为视频区域添加手势响应,当上下拖动时调整播放器音量大小,使用拖动手势(PanGesture)。


PanGesture(value?:{ fingers?:number, direction?:PanDirection, distance?:number})
复制代码


拖动手势用于触发拖动手势事件,滑动达到最小滑动距离(默认值为 5vp)时拖动手势识别成功,拥有三个可选参数:


  • fingers:用于声明触发拖动手势所需要的最少手指数量,最小值为 1,最大值为 10,默认值为 1。

  • direction:用于声明触发拖动的手势方向,此枚举值支持逻辑与(&)和逻辑或(|)运算。默认值为 PanDirection.All。

  • distance:用于声明触发拖动的最小拖动识别距离,单位为 vp,默认值为 5。


创建声音控制组件 SetVolume,使用 AudioKit 的 AVVolumePanel 显示音量。在 views 目录下新建 SetVolume.ets 文件


import { AVVolumePanel } from '@kit.AudioKit';
@Componentexport struct SetVolume { @Prop volume: number = 5 @Prop volumeVisible: boolean = false
build() { Column() { AVVolumePanel({ volumeLevel: this.volume, volumeParameter: { position: { x: 50, y: 900 } } }) .width(10) } .visibility(this.volumeVisible ? Visibility.Visible : Visibility.Hidden) .height('50%') }}
复制代码


为视频和字幕区域添加手势事件,修改 Index.ets 文件


import { SetVolume } from '../views/SetVolume'
...const SET_VOLUME_TIME_OUT = 5000 // VolumeTimer: 5s
...@State volumeVisible: boolean = false; //音量调解是否可见@State @Watch('onVolumeUpdate') volume: number = 5;private volumeTimeout: number = 0; // VolumeTimer ID
onVolumeUpdate() { AppStorage.setOrCreate('isMuted', this.volume <= 0.0); this.avPlayerController.videoMuted(this.volume <= 0.0);}setVolumeTimer(): void { this.volumeTimeout = setTimeout(() => { this.volumeVisible = false }, SET_VOLUME_TIME_OUT)} ...Stack(){// 音量调节 SetVolume({ volume: this.volume, volumeVisible: this.volumeVisible }) ...}.gesture( PanGesture({ direction: PanDirection.Vertical }) .onActionStart(() => { }) .onActionUpdate((event: GestureEvent) => { this.volumeVisible = true; let curVolume = this.volume - this.getUIContext().vp2px(event.offsetY) / this.windowHeight; curVolume = curVolume >= 15.0 ? 15.0 : curVolume; curVolume = curVolume <= 0.0 ? 0.0 : curVolume; this.volume = curVolume; hilog.info(CommonConstants.LOG_DOMAIN, TAG, 'AVPlayManage', 'AVPlayer', `this volumn is: ` + this.volume); }) .onActionEnd(() => { this.setVolumeTimer(); }) )
复制代码


需要说明的时,上下滑动的拖动手势,需要真机才能测试。

9、倍速播放

当长按整个屏幕区域时,实现倍速快进播放,使用长按手势(LongPressGesture)。


LongPressGesture(value?:{fingers?:number, repeat?:boolean, duration?:number})
复制代码


长按手势用于触发长按手势事件,拥有三个可选参数:


  • fingers:用于声明触发长按手势所需要的最少手指数量,最小值为 1,最大值为 10,默认值为 1。

  • repeat:用于声明是否连续触发事件回调,默认值为 false。

  • duration:用于声明触发长按所需的最短时间,单位为毫秒,默认值为 500。


为界面绑定长按手势,在手势处理函数中进行播放速度控制。修改 Index.ets 文件:


import { media } from '@kit.MediaKit';
const CASE_ZERO = 0;const CASE_THREE = 3;... @State @Watch('onSpeedSelectUpdate') speedSelect: number = 0; @State speedList: Resource[] = [$r('app.string.video_speed_1_0X'), $r('app.string.video_speed_1_25X'), $r('app.string.video_speed_1_75X'), $r('app.string.video_speed_2_0X')];...
onSpeedSelectUpdate() { AppStorage.setOrCreate('speedName', this.speedList[this.speedSelect]); AppStorage.setOrCreate('speedIndex', this.speedSelect);}

Stack(){ ...}.gesture( LongPressGesture({ repeat: true }) .onAction(() => { this.speedSelect = CASE_THREE this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X); }) .onActionEnd(() => { this.speedSelect = CASE_ZERO this.avPlayerController.videoSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X); }) )
复制代码


长按界面,视频就会按 2 倍速播放,松手后回复正常倍速播放。

五、总结

本研究中,使用了 HarmonyOS SDK 的媒体服务(Media Kit)的 AVPlayer 来实现音视频的播放控制,相关功能总结如下:


  • 视频倍速切换、暂停、播放、切换视频、视频跳转的功能接口都封装在 AvPlayerController.ets


  • 使用 media.createAVPlayer()来获取 AVPlayer 对象;

  • 倍速切换:选择不同倍速时调用 avPlayer.setSpeed(speed: PlaybackSpeed);

  • 暂停、播放:点击暂停、播放按钮时调用 avPlayer.pause()、avPlayer.play();

  • 视频跳转:在拖动滑动条时调用 avPlayer.seek();

  • 静音播放:点击静音按钮时调用 avPlayer.setMediaMuted();

  • 音量设置:为元素添加手势上下滑动监听 PanGesture,滑动时时显示 AVVolumePanel 组件并根据滑动距离计算音量 volume 值;

  • 窗口缩放模式设置:选择不同的窗口缩放模式时设置 avPlayer 的 videoScaleType 属性值;

  • 长按倍速:为元素添加手势长按监听 LongPressGesture,长按时调用 avPlayer.setSpeed(speed: PlaybackSpeed);

  • 循环播放:在视频 prepared 状态下,设置 avPlayer 的 loop 属性值为 true。

  • 字幕挂载:视频初始化时调用 avPlayer.addSubtitleFromFd()设置外挂字幕资源。


《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新 AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!


关注后,评论区领取本案例项目代码!

发布于: 刚刚阅读数: 3
用户头像

网名:黑马腾云 2020-06-22 加入

80后创业者、高级架构师,带你轻松学编程!著有《Node.js全栈开发从入门到项目实战》、《Java企业级软件开发》、《HarmonyOS应用开发实战》等书籍。“自学帮”公众号主。

评论

发布
暂无评论
万字长文,手把手教你2小时实现鸿蒙版视频播放器(附源码),建议先收藏_鸿蒙_程序员潘Sir_InfoQ写作社区