大家好,我是潘 Sir,持续分享 IT 技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新 AI+编程、企业级项目实战等原创内容、欢迎关注!
随着移动互联网和 5G 网络的普及,短视频已成为人们日常生活中必不可少的娱乐工具。本文以视频播放功能为例,探讨基于 HarmonyOS 实现视频的播放和控制实现。文章较长,码了整整一天,建议先收藏!
ps:代码较多,可以先根据文章整体理清思路,然后评论区回复领取代码进行研究。
一、需求分析
1、应用场景
视频播放场景很多:如老牌的视频网站(优酷、腾讯视频)、大家熟悉的 B 站以及短视频平台(抖音、视频号、快手等),播放器都是核心功能。
基于以上场景,本文实现本地视频播放器功能,包括:视频加载、播放、暂停、退出,跳转播放,静音播放,循环播放,窗口缩放模式设置,倍速设置,音量设置,字幕挂载等功能。在鸿蒙操作系统(HarmonyOS)上借助 HarmonyOS SDK 提供媒体服务(Media Kit)中的 AVPlayer,开发鸿蒙原生应用 APP,完成上述功能。
2、实现效果
部分效果见下图:
项目运行成功后,视频自动开始播放;
点击暂停/播放按钮,控制视频暂停播放;
滑动视频进度条,视频跳转到指定位置,在视频中间会出现时间进度方便用户查看视频进度;
点击倍速,可以选择 1.0、1.25、1.75、2.0 进行倍速调节;
点击静音按钮,可以设置静音模式播放;
点击窗口缩放模式按钮,可以选择拉伸至与窗口等大、缩放至最短边填满窗口;
长按屏幕,控制视频 2.0 倍速播放;
上下滑动屏幕,可以设置视频播放音量;
视频下方显示字幕,并可以点击语言切换按钮切换字幕;
视频自动循环播放;
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 共同实现。
图中的数字标注表示需要数据与外部模块的传递。
应用从 XComponent 组件获取窗口 SurfaceID。
应用把媒体资源、SurfaceID 传递给 AVPlayer 接口。
Player Framework 把视频 ES 数据流输出给解码 HDI,解码获得视频帧(NV12/NV21/RGBA)。
Player Framework 把音频 PCM 数据流输出给 Audio Framework,Audio Framework 输出给音频 HDI。
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
@Component
struct 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';
@Component
export 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;
@CustomDialog
export 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;
@CustomDialog
export 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;
@CustomDialog
export 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;
@Observed
export 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 ratio
const SURFACE_HEIGHT = 1.78; // Surface height ratio
const TAG = '[Index]';
const PROPORTION = 0.99; // Screen Percentage
const 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';
@Component
export 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 来实现音视频的播放控制,相关功能总结如下:
使用 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+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!
关注后,评论区领取本案例项目代码!
评论