基于XComponent的视频播放器高性能体验
场景一:视频播放预加载,边下边播
方案
1. 创建一个沙箱文件,并获取沙箱文件的readFd和writeFd。
2. 通过.new rcp.Request(DOWNLOAD_URL)创建网络下载请求request,配置request的TracingConfiguration,在onDataReceive回调中通过fs.writeSync传入沙箱文件的writeFd,将下载的数据流写入本地沙箱文件,将fs.writeSync返回写入字节大小作为网络下载大小downloadSize,根据downloadSize和下载大小(默认1024*1024字节,AVPlayer默认缓存为1M)配置request的transferRange属性,控制网络下载的起始字节和结束字节。
3. 通过RCP的session.fetch传入request下载获取网络视频资源。
4. 配置AVPlayer的datasrc属性,在datasrc的回调函数中,通过fs.readSync传入沙箱文件的readFd,将沙箱文件的数据写入内存buffer,沙箱文件大小为0时开启网络下载,当pos(表示填写的数据在资源文件中的位置)小于沙箱文件100kb时,再次开启网络下载进而实现分段下载,该回调函数在AVPlayer解析数据时触发,在边下边播的场景中,会不断触发该回调。
5. 设置AVPlayer播放资源,将datasrc设置给AVPlayer。
核心代码
控制网络下载的起始字节和结束字节。
function download(length: number) {
console.info('MineRcp download from = ' + downloadSize + 'to = ' + (downloadSize + length - 1));
request.transferRange = {
from: downloadSize,
to: downloadSize + length - 1,
};
downloadStarted = true;
session.fetch(request).then(() => {
downloadStarted = false;
}).catch(() => {
downloadStarted = false;
});
}
onDataReceive回调中通过fs.writeSync传入沙箱文件的writeFd,将下载的数据流写入本地沙箱文件。
request.configuration = {
tracing: {
httpEventsHandler: {
onDataReceive: (incomingData: ArrayBuffer) => {
const writeLength = fs.writeSync(this.writeFd, GetDecodedBuffer(incomingData));
downloadSize += writeLength;
console.info('MineRcp recieve Length = ' + writeLength.toString() + " , recieve = " + downloadSize);
return incomingData.byteLength;
}
}
}
};
配置AVPlayer的datasrc属性。
let src: media.AVDataSrcDescriptor = {
fileSize: -1,
callback: (buf: ArrayBuffer, length: number, pos?: number) => {
// buffer,ArrayBuffer类型,表示被填写的内存,必选。
//
// - length,number类型,表示被填写内存的最大长度,必选。
//
// - pos,number类型,表示填写的数据在资源文件中的位置,可选,当fileSize设置为-1时,该参数禁止被使用。
// - 返回值,number类型,返回要填充数据的长度。
let num = 0;
if (buf === undefined || length === undefined) {
return -1;
}
num = fs.readSync(this.readFd, buf);
console.info('MineRcp cacheBuffer after checkBuffer Length = ' + num.toString() + ', pos: ' + pos +
', downloadSize: ' +
downloadSize);
if (num > 0) {
if (pos != undefined && downloadSize - pos < 100 * 1024) {
console.info('MineRcp data not enough, download more');
let downloadLength = 1024 * 1024;
if (this.fileSize - downloadSize <= downloadLength) {
downloadLength = this.fileSize - downloadSize;
}
if (!downloadStarted) {
download(downloadLength);
}
}
return num;
}
if (num === 0) {
console.info('MineRcp no data read, download more');
if (!downloadStarted) {
let downloadLength = 1024 * 1024;
if (this.fileSize - downloadSize <= downloadLength) {
downloadLength = this.fileSize - downloadSize;
}
download(downloadLength);
}
return 0;
}
return -1;
}
}
src.fileSize = this.fileSize;
this.isSeek = false; // 支持seek操作
this.player.dataSrc = src;
场景二:同一页面内视频播放横竖屏全屏切换无缝转场
方案
设置竖屏和全屏两个按钮,分别添加点击事件。
1. 首先设置窗口方向,通过window的setPreferredOrientation接口来设置屏幕方向,接口文档:setPreferredOrientation。
2. 设置沉浸式窗口,为使视屏能充斥整个屏幕,防止屏幕上下两边的任务栏等区域影响全屏观看效果,通过window的setWindowLayoutFullScreen接口设置窗口沉浸式,接口文档:setWindowLayoutFullScreen。
3. 根据屏幕状态点击全屏按钮切换到相应屏幕。
核心代码
// 设置窗口方向
setR(orientation: number) {
window.getLastWindow(getContext(this)).then((win) => {
win.setPreferredOrientation(orientation).then((data) => {
console.log('setWindowOrientation: ' + orientation + ' Succeeded. Data: ' + JSON.stringify(data));
}).catch((err: string) => {
console.log('setWindowOrientation: Failed. Cause: ' + JSON.stringify(err));
});
}).catch((err: string) => {
console.log('setWindowOrientation: Failed to obtain the top window. Cause: ' + JSON.stringify(err));
});
}
//设置沉浸式窗口
setFullScreen(isLayoutFullScreen: boolean) {
window.getLastWindow(getContext(this)).then((win) => {
win.setWindowLayoutFullScreen(isLayoutFullScreen, (err: BusinessError) => {
const errCode: number = err.code;
if (errCode) {
console.error('Failed to set the window layout to full-screen mode. Cause:' + JSON.stringify(err));
return;
}
console.info('Succeeded in setting the window layout to full-screen mode.');
});
}).catch((err: string) => {
console.log('setWindowOrientation: Failed to obtain the top window. Cause: ' + JSON.stringify(err));
});
}
// 横屏按钮
async switchFullScreen() {
if (this.isFullScreen) {
// 切换正常播放
await this.setR(1);
await this.setFullScreen(false)
this.isFullScreen=false;
return
} else {
// 切换横屏
if (this.isVerticalScreen) {
this.isVerticalScreen=! this.isVerticalScreen;
}
this.setR(2);
this.setFullScreen(true)
this.isFullScreen=! this.isFullScreen
}
}
// 竖屏全屏按钮
async switchVerticalFullScreen() {
if (this.isVerticalScreen) {
//切换正常播放
// await this.setR(1);
await this.setFullScreen(false)
this.isVerticalScreen = !this.isVerticalScreen
// this.isVerticalScreen=!this.isVerticalScreen
return
} else {
if (this.isFullScreen) {
//切换 竖屏
this.isFullScreen = !this.isFullScreen
}
await this.setR(1);
await this.setFullScreen(true)
this.isVerticalScreen = !this.isVerticalScreen
}
}
场景三:跨页面视频播放无缝转场
方案
1. 在page1页面通过GlobalContext将AVPlayer当做全局单例变量放到Map<string, media.AVPlayer>里面。
2. 通过router跳转到page2页面,通过Map<string, media.AVPlayer>获取单例AVPlayer,将page2页面的Xcomponent的SurfaceId设置给AVPlayer。
核心代码
import { media } from '@kit.MediaKit';
import { router } from '@kit.ArkUI';
export class GlobalContext {
private static instance: GlobalContext;
private _objects = new Map<string, media.AVPlayer>();
public static getContext(): GlobalContext {
if (!GlobalContext.instance) {
GlobalContext.instance = new GlobalContext();
}
return GlobalContext.instance;
}
getObject(value: string): media.AVPlayer | undefined {
return this._objects.get(value);
}
setObject(key: string, objectClass: media.AVPlayer): void {
this._objects.set(key, objectClass);
}
}
// ...
onJumpClick(): void {
router.replaceUrl({
url: 'pages/player' // 目标url
}, (err) => {
if (err) {
console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
return;
}
console.info('Invoke pushUrl succeeded.');
})
}
// ...
将AVPlayer放进全局map。
if (this.player) {
GlobalContext.getContext().setObject('value', this.player);
}
.onLoad(() => {
this.mXComponentController.setXComponentSurfaceSize({ surfaceWidth: this.xComponentWidth, surfaceHeight: this.xComponentHeight });
this.surfaceID=this.mXComponentController.getXComponentSurfaceId();
console.info('onLoad '+this.surfaceID)
//取出全局map里面的AVPlayer
avPlayer=GlobalContext.getContext().getObject('value');
if (avPlayer) {
avPlayer.surfaceId=this.surfaceID;
}
})
场景四:视频截图并保存到相册
方案
1. 通过createPixelMapFromSurfaceSync传入surfaceID和region获取pixelMap。
2. 通过imagePackerApi.packToFile将pixelMap编码保存到沙箱图片。
3. 通过saveButton将图片保存到相册。
核心代码
//视频截图
async getVideoFrame(): Promise<PixelMap> {
// let rect: SurfaceRect = this.xComponentController?.getXComponentSurfaceRect() as SurfaceRect;
if (this.player) {
region = {
x: 0 as number,
y: 0 as number,
size: { width: Math.trunc(this.player?.width), height: Math.trunc(this.player?.height) }
};
}
this.pixma = image.createPixelMapFromSurfaceSync(this.surfaceID, region);
return this.pixma;
}
//保存到相册
async saveToAlbum() {
let file = fs.openSync(fileUri, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
const imagePackerApi = image.createImagePacker();
let packOpts: image.PackingOption = { format: "image/jpeg", quality: 100 };
imagePackerApi.packToFile(this.pixma, file.fd, packOpts).then(async () => {
// 直接打包进文件
try {
let context = getContext();
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
// 需要确保fileUri对应的资源存在
let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, fileUri);
await phAccessHelper.applyChanges(assetChangeRequest);
console.info('createAsset successfully, uri: ' + assetChangeRequest.getAsset().uri);
} catch (err) {
console.error(`create asset failed with error: ${err.code}, ${err.message}`);
}
}).catch((error: BusinessError) => {
console.error('Failed to pack the image. And the error is: ' + error);
})
}
场景五:切换播放hdrvivid视频
方案
1. 通过reset方法使avplayer进入adle状态。
2. 设置fdSrc属性,重置hdrvivid视频播放资源。
核心代码
async resetHdrVivid() {
let context = getContext(this) as common.UIAbilityContext;
let fileDescriptor = await context.resourceManager.getRawFd('hdrVivid.mp4');
let avFileDescriptor: media.AVFileDescriptor =
{ fd: fileDescriptor.fd, offset: fileDescriptor.offset, length: fileDescriptor.length };
// 为fdSrc赋值触发initialized状态机上报
if (this.player) {
this.player.fdSrc = avFileDescriptor;
}
}
场景六:视频同步缩放
方案
1. 通过xComponentController.getXComponentSurfaceRect()获取当前视频surface大小。
2. xComponentController!.setXComponentSurfaceRect设置当前视频surface大小。
核心代码
let rect: SurfaceRect = this.xComponentController?.getXComponentSurfaceRect() as SurfaceRect;
if (this.ScalingFlag) {
this.xComponentController!.setXComponentSurfaceRect({
offsetX: 0,
offsetY: 0,
surfaceWidth:rect.surfaceWidth,
surfaceHeight: rect.surfaceHeight/2
});
this.ScalingFlag=!this.ScalingFlag
}else {
this.xComponentController!.setXComponentSurfaceRect({
offsetX: 0,
offsetY: 0,
surfaceWidth:rect.surfaceWidth,
surfaceHeight: rect.surfaceHeight*2
});
this.ScalingFlag=!this.ScalingFlag
}
场景七:视频滑动调整音量、亮度
方案
1. 添加视频音量,亮度滑块进度条。
2. 将音量,屏幕的亮度和滑块的value实现双向绑定。
3. XComponent左侧添加垂直拖动手势,根据滑动偏移量,通过player.setVolume调整音量。
4. XComponent右侧添加垂直拖动手势,根据滑动偏移量,通过window.setWindowBrightness调整亮度。
5. 通过触摸点的X轴坐标控制音量和亮度滑块的生效区域。
核心代码
音量滑块。
Slider({
value: this.currentVolume,
min: 0,
max: 1,
style: SliderStyle.OutSet,
step: 0.01
})
.width('60%')
.id('Slider')
.blockColor(Color.White)
.trackColor(Color.Gray)
.selectedColor($r('app.color.slider_selected'))
.showTips(false)
.onChange((value: number, mode: SliderChangeMode) => {
})
添加拖动手势,动态调整音量。
.onActionUpdate((event: GestureEvent | undefined) => {
if (event) {
if (this.isFullScreen&&this.currentPositionX<150) {
//todo 现因控制
this.isVolume= Visibility.Visible;
let preVolume = this.currentVolume - event.offsetY / 8000
if (preVolume > 1) {
preVolume = 1
}
if (preVolume < 0) {
preVolume = 0
}
this.currentVolume = preVolume
this.player?.setVolume(preVolume);
}
if (!this.isFullScreen&&this.currentPositionX<80) {
//todo 现因控制
this.isVolume= Visibility.Visible;
let preVolume = this.currentVolume - event.offsetY / 8000
if (preVolume > 1) {
preVolume = 1
}
if (preVolume < 0) {
preVolume = 0
}
this.currentVolume = preVolume
this.player?.setVolume(preVolume);
}
}
})
.onActionEnd(() => {
this.isVolume = Visibility.None;
})
显隐控制亮度滑块。
Slider({
value: this.windowBrightness,
min: 0,
max: 1,
style: SliderStyle.OutSet,
step: 0.1
})
.width('60%')
.id('Slider')
.blockColor(Color.White)
.visibility(this.isBrightness)
.trackColor(Color.Gray)
.selectedColor($r('app.color.slider_selected'))
.showTips(false)
.onChange((value: number, mode: SliderChangeMode) => {
})
添加拖动手势,动态调整亮度。
PanGesture(this.panOption)
.onActionStart((event: GestureEvent | undefined) => {
console.info('Pan start');
this.isBrightness = Visibility.Visible;
})// 当触发拖动手势时,根据回调函数修改组件的布局位置信息
.onActionUpdate((event: GestureEvent | undefined) => {
if (event) {
let preWindowBrightness = this.windowBrightness - event.offsetY / 10000
if (preWindowBrightness > 1) {
preWindowBrightness = 1
}
if (preWindowBrightness < 0) {
preWindowBrightness = 0
}
this.windowBrightness = preWindowBrightness
this.setWindowBrightness(preWindowBrightness);
console.log('preWindowBrightness' + event.offsetY / 10000);
}
})
.onActionEnd(() => {
this.isBrightness = Visibility.None;
})
场景八:视频长按快进
方案
1. 给XComponent组件添加长按手势。
2. 长按动作触发时通过setSpeed设置为2倍速,长按动作结束设置为1倍速。
核心代码
//长按手势
LongPressGesture({ repeat: false })// 由于repeat设置为true,长按动作存在时会连续触发,触发间隔为duration(默认值500ms)
.onAction((event: GestureEvent) => {
this.player?.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X);
})// 长按动作一结束触发
.onActionEnd((event: GestureEvent) => {
this.player?.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X);
})
场景九:视频滑动seek,实现进度预览
方案
1. 添加视频滑块进度条。
2. 将进度条的值和avplayer的当前时间实现双向绑定。
3. 定时任务刷新视频当前播放时间。
4. 给XComponent添加水平拖动手势实现seek。
5. 通过用createAVImageGenerator()创建AVImageGenerator对象,设置AVImageGenerator对象的属性fdSrc,fdSrc需要和AVPlayer的视频源保持一致。
6. 在滑动手势触发时,根据偏移量生成seekTime,AVPlayer正常播放,通过AVImageGenerator对象的fetchFrameByTime方法传入seekTime(注意换算成微秒)生成pixelMap,实现进度预览。
7. 滑动手势结束,AVPlayer根据最终的seektime进行seek。
8. 拖动滑块时,滑块移动中,根据滑块value值调用fetchFrameByTime生成pixelMap,实现进度预览。
9. 拖动滑块结束,AVPlayer根据滑块最终的value值进行seek。
核心代码
Row() {
Image(this.pauseFlag ? $r('app.media.ic_video_play') : $r('app.media.ic_video_pause'))// 暂停/播放
.id('play')
.width($r('app.float.size_40'))
.height($r('app.float.size_40'))
.onClick(() => {
if (this.pauseFlag) {
this.avPlayManage.pause();
this.pauseFlag = false;
} else {
this.avPlayManage.play();
this.pauseFlag = true;
}
})
// 左侧时间
Text(timeConvert(this.currentTime))
.fontColor(Color.White)
.textAlign(TextAlign.End)
.fontWeight(FontWeight.Regular)
.margin({ left: $r('app.float.size_10') })
}
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)
}
.layoutWeight(1)
Row() {
// 右侧时间
Text(timeConvert(this.durationTime))
.fontColor(Color.White)
.fontWeight(FontWeight.Regular)
}
case 'prepared': // prepare调用成功后上报该状态机
console.info('AVPlayer state prepared called.');
if (this.player) {
this.player.loop = true;
this.durationTime = this.player.duration;
this.currentTime = this.player.currentTime;
}
setInterval(() => { // 更新当前时间
if (!this.isSwiping && this.player?.currentTime) {
this.currentTime = this.player?.currentTime;
}
}, SET_INTERVAL);
添加拖动手势,实现seek。
PanGesture(this.panOptionSeek)
.onActionStart((event: GestureEvent | undefined) => {
console.info('Pan start');
this.isPullBar=Visibility.Visible;
this.isPullBarFlag=true;
this.isSwiping=true
console.info('this.isPullBar st '+this.isPullBar)
this.lastFetch= Math.trunc(this.currentTime/1000)
})// 当触发拖动手势时,根据回调函数修改组件的布局位置信息
.onActionUpdate(async (event: GestureEvent | undefined) => {
if (event&&this.player) {
console.log("currentTime log"+ this.currentTime);
this.seekTime = this.player.currentTime + event.offsetX*25
if (this.seekTime > this.durationTime) {
this.seekTime = this.durationTime
}
if (this.seekTime < 0) {
this.seekTime = 0
}
console.log('currentTime seek '+this.seekTime+'off '+event.offsetX);
console.log('currentTime seekTime seek '+timeConvert(this.seekTime));
this.MayFetch=Math.trunc(this.seekTime/1000);
if (this.MayFetch!=this.lastFetch) {
this.testFetchFrameByTime(this.MayFetch*1000*1000);
this.lastFetch=this.MayFetch;
}
// this.playerSeek?.seek(this.seekTime,2);
console.log("this.currentTime" + this.currentTime);
console.log("event.offsetX" + event.offsetX);
console.log("this.durationTime" + this.durationTime);
}
})
.onActionEnd(async () => {
if (this.player) {
this.player.seek(this.lastFetch*1000,2);
}
//seektime换成秒
this.isSwiping=false;
console.info('this.isPullBar End'+this.isPullBar)
console.info('this.isPullBar End'+this.isPullBarFlag)
// 定时任务,三秒后隐藏进度条
clearInterval(this.intervalID);
this.interval();
})
滑块组件滑动实现seek。
.onChange(async (value: number, mode: SliderChangeMode) => {
if (!this.isSwiping) {
if (mode == SliderChangeMode.Begin) {
console.info('mode Begin' + mode)
this.isSeeking = true;
this.isPullBarFlag=true;
this.isPullBar=Visibility.Visible;
this.lastFetch= Math.trunc(value/1000)
return
}
if (mode == SliderChangeMode.Moving) {
console.info('mode Moving '+ mode)
this.seekTime=value;
this.MayFetch=Math.trunc(this.seekTime/1000);
if (this.MayFetch!=this.lastFetch) {
this.testFetchFrameByTime(this.MayFetch*1000*1000);
this.lastFetch=this.MayFetch;
return
}
return
}
if (mode == SliderChangeMode.End) {
console.info('mode End '+ mode)
this.isSeeking = false
this.avPlayManage?.seek(this.lastFetch*1000, 2)
//定时任务,三秒后隐藏进度条
clearInterval(this.intervalID);
this.interval();
return
}
if (mode == SliderChangeMode.Click) {
console.info('mode Click'+ mode)
this.avPlayManage.seek(value, 2)
}
}
})
场景十:视频单击显示进度条,双击暂停
方案
1. 给Xcomponent添加单击手势,控制视频进度条组件显隐。
2. 添加定时任务,单击手势结束3秒后隐藏视频进度条。
3. 给Xcomponent添加双击手势,实现视频的播放和暂停。
核心代码
// 绑定count为1的TapGesture
TapGesture({ count: 1 })
.onAction((event: GestureEvent|undefined) => {
if(event&&!this.isSwiping){
// this.value = JSON.stringify(event.fingerList[0]);
this.isPullBarFlag=!this.isPullBarFlag
this.pullBar()
}
})
pullBar():void{
if (this.isPullBarFlag) {
//拉起
this.isPullBar=Visibility.Visible;
//定时任务3秒后关闭
this.interval();
}else {
this.isPullBar=Visibility.None;
this.isPullBarFlag=false;
}
}
interval():void{
if (!this.intervalID){
this.intervalID=setTimeout(() => { //
if (!this.isSeeking! &&this.isSwiping && this.isPullBarFlag) {
this.isPullBar=Visibility.None
this.isPullBarFlag=false;
}
}, 3000);
}else {
clearInterval(this.intervalID)
this.intervalID=setTimeout(() => { //
if (!this.isSeeking!&&!this.isSwiping && this.isPullBarFlag) {
this.isPullBar=Visibility.None
this.isPullBarFlag=false;
}
}, 3000);
}
}
TapGesture({ count: 2 })
.onAction((event: GestureEvent|undefined) => {
if(event){
// this.value = JSON.stringify(event.fingerList[0]);
console.info('TapGesture'+this.isPlayingFlag)
if (this.isPlayingFlag) {
this.player?.pause();
this.isPlayingFlag = false;
}else {
this.player?.play()
this.isPlayingFlag = true;
}
}
})