【HarmonyOS开发】案例-短视频应用

大前端腾宇

共 30075字,需浏览 61分钟

 · 2024-04-10

d0fb35e7961b559d63b37d3958a1558c.webp

前段时间看到一篇文章,但是没有源码,是一个仿写抖音的文章,最近也在看这块,顺便写个简单的短视频小应用。

技术点拆分

1、http请求数据;

2、measure计算文本宽度

3、video播放视频;

4、onTouch上滑/下拉切换视频;

5、List实现滚动加载;

效果展示

a424f5dbe89b9849cdd904a92faa4a28.webp

还是先上红包封面吧

http请求数据

通过对@ohos.net.http进行二次封装,进行数据请求。

1、封装requestHttp;

      
        import http from '@ohos.net.http';
      
      
        
          
// 1、创建RequestOption.ets 配置类 export interface RequestOptions { url?: string; method?: RequestMethod; // default is GET queryParams ?: Record<string, string>; extraData?: string | Object | ArrayBuffer; header?: Object; // default is 'content-type': 'application/json' }
export enum RequestMethod { OPTIONS = "OPTIONS", GET = "GET", HEAD = "HEAD", POST = "POST", PUT = "PUT", DELETE = "DELETE", TRACE = "TRACE", CONNECT = "CONNECT" }
/** * Http请求器 */ export class HttpCore { /** * 发送请求 * @param requestOption * @returns Promise */ request<T>(requestOption: RequestOptions): Promise<T> { return new Promise<T>((resolve, reject) => { this.sendRequest(requestOption) .then((response) => { if (typeof response.result !== 'string') { reject(new Error('Invalid data type'));
} else { let bean: T = JSON.parse(response.result); if (bean) { resolve(bean); } else { reject(new Error('Invalid data type,JSON to T failed')); }
} }) .catch((error) => { reject(error); }); }); }
private sendRequest(requestOption: RequestOptions): Promise<http.HttpResponse> { // 每一个httpRequest对应一个HTTP请求任务,不可复用 let httpRequest = http.createHttp();
let resolveFunction, rejectFunction; const resultPromise = new Promise<http.HttpResponse>((resolve, reject) => { resolveFunction = resolve; rejectFunction = reject; });
if (!this.isValidUrl(requestOption.url)) { return Promise.reject(new Error('url格式不合法.')); }
let promise = httpRequest.request(this.appendQueryParams(requestOption.url, requestOption.queryParams), { method: requestOption.method, header: requestOption.header, extraData: requestOption.extraData, // 当使用POST请求时此字段用于传递内容 expectDataType: http.HttpDataType.STRING // 可选,指定返回数据的类型 });
promise.then((response) => { console.info('Result:' + response.result); console.info('code:' + response.responseCode); console.info('header:' + JSON.stringify(response.header));
if (http.ResponseCode.OK !== response.responseCode) { throw new Error('http responseCode !=200'); } resolveFunction(response);
}).catch((err) => { rejectFunction(err); }).finally(() => { // 当该请求使用完毕时,调用destroy方法主动销毁。 httpRequest.destroy(); }) return resultPromise; }

private appendQueryParams(url: string, queryParams: Record<string, string>): string { // todo 使用将参数拼接到url return url; }
private isValidUrl(url: string): boolean { //todo 实现URL格式判断 return true; } }
// 实例化请求器 const httpCore = new HttpCore();

export class HttpManager { private static mInstance: HttpManager;
// 防止实例化 private constructor() { }
static getInstance(): HttpManager { if (!HttpManager.mInstance) { HttpManager.mInstance = new HttpManager(); } return HttpManager.mInstance; }

request<T>(option: RequestOptions): Promise<T> { return new Promise(async (resolve, reject) => { try { const data: any = await httpCore.request(option) resolve(data) } catch (err) { reject(err) } }) } }
export default HttpManager;

2、使用request Http请求视频接口;

      
        import httpManager, { RequestMethod } from '../../utils/requestHttp';
      
      
        
          
@State total: number = 0 @State listData: Array<ResultType> = [] private url: string = "https://api.apiopen.top/api/getHaoKanVideo?size=10"; private page: number = 0
private httpRequest() { httpManager.getInstance() .request({ method: RequestMethod.GET, url: `${this.url}&page=${this.page}` //公开的API }) .then((res: resultBean) => { this.listData = [...this.listData, ...res.result.list]; this.total = res.result.total; this.duration = 0; this.rotateAngle = 0; }) .catch((err) => { console.error(JSON.stringify(err)); }); }

measure计算文本宽度

      
        import measure from '@ohos.measure'
      
      
        
          
@State textWidth : number = measure.measureText({ //要计算的文本内容,必填 textContent: this.title, }) // this.textWidth可以获取this.title的宽度

video播放视频

1、通过videoController控制视频的播放和暂停,当一个视频播放结束,播放下一个

      
        private videoController: VideoController = new VideoController()
      
      
        
          
Video({ src: this.playUrl, previewUri: this.coverUrl, controller: this.videoController }) .width('100%') .height('100%') .borderRadius(3) .controls(false) .autoPlay(true) .offset({ x: 0, y: `${this.offsetY}px` }) .onFinish(() => { this.playNext() })

2、Video的一些常用方法

属性:

名称 参数类型 描述
muted boolean 是否静音。
默认值:false
autoPlay boolean 是否自动播放。
默认值:false
controls boolean 控制视频播放的控制栏是否显示。
默认值:true
objectFit ImageFit 设置视频显示模式。
默认值:Cover
loop boolean 是否单个视频循环播放。
默认值:false

事件:

名称 功能描述
onStart(event:() => void) 播放时触发该事件。
onPause(event:() => void) 暂停时触发该事件。
onFinish(event:() => void) 播放结束时触发该事件。
onError(event:() => void) 播放失败时触发该事件。
onPrepared(callback:(event: { duration: number }) => void) 视频准备完成时触发该事件。
duration:当前视频的时长,单位为秒(s)。
onSeeking(callback:(event: { time: number }) => void) 操作进度条过程时上报时间信息。
time:当前视频播放的进度,单位为s。
onSeeked(callback:(event: { time: number }) => void) 操作进度条完成后,上报播放时间信息。
time:当前视频播放的进度,单位为s。
onUpdate(callback:(event: { time: number }) => void) 播放进度变化时触发该事件。
time:当前视频播放的进度,单位为s。
onFullscreenChange(callback:(event: { fullscreen: boolean }) => void) 在全屏播放与非全屏播放状态之间切换时触发该事件。
fullscreen:返回值为true表示进入全屏播放状态,为false则表示非全屏播放。

onTouch上滑/下拉切换视频

通过手指按压时,记录Y的坐标,移动过程中,如果移动大于50,则进行上一个视频或者下一个视频的播放。

      
        private onTouch = ((event) => {
      
      
          switch (event.type) {
      
      
            case TouchType.Down: // 手指按下
      
      
              // 记录按下的y坐标
      
      
              this.lastMoveY = event.touches[0].y
      
      
              break;
      
      
            case TouchType.Up: // 手指按下
      
      
              this.offsetY = 0
      
      
              this.isDone = false
      
      
              break;
      
      
            case TouchType.Move: // 手指移动
      
      
              const offsetY = (event.touches[0].y - this.lastMoveY) * 3;
      
      
              let isDownPull = offsetY < -80
      
      
              let isUpPull = offsetY > 80
      
      
              this.lastMoveY = event.touches[0].y
      
      
              if(isUpPull || isDownPull) {
      
      
                this.offsetY = offsetY
      
      
                this.isDone = true
      
      
              }
      
      
        
          
console.log('=====offsetY======', this.offsetY, isDownPull, isUpPull)
if (isDownPull && this.isDone) { this.playNext() } if (isUpPull && this.isDone) { this.playNext() } break; } })

List实现滚动加载

1、由于视频加载会比较慢,因此List中仅展示一个视频的图片,点击播放按钮即可播放;

2、通过onScrollIndex监听滚动事件,如果当前数据和滚动的index小于3,则进行数据下一页的请求;

      
        List({ scroller: this.scroller, space: 12 }) {
      
      
          ForEach(this.listData, (item: ResultType, index: number) => {
      
      
            ListItem() {
      
      
              Stack({ alignContent: Alignment.TopStart }) {
      
      
                Row() {
      
      
                  Image(item.userPic).width(46).height(46).borderRadius(12).margin({ right: 12 }).padding(6)
      
      
                  Text(item.title || '标题').fontColor(Color.White).width('80%')
      
      
                }
      
      
                .width('100%')
      
      
                .backgroundColor('#000000')
      
      
                .opacity(0.6)
      
      
                .alignItems(VerticalAlign.Center)
      
      
                .zIndex(9)
      
      
        
          
Image(item.coverUrl) .width('100%') .height(320) .alt(this.imageDefault)
Row() { Image($rawfile('play.png')).width(60).height(60) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .opacity(0.8) .zIndex(100) .onClick(() => { this.currentPlayIndex = index; this.coverUrl = item.coverUrl; this.playUrl = item.playUrl; this.videoController.start() }) } .width('100%') .height(320) }   }) } .divider({ strokeWidth: 1, color: 'rgb(247,247,247)', startMargin: 60, endMargin: 0 }) .onScrollIndex((start, end) => { console.log('============>', start, end) if(this.listData.length - end < 3) { this.page = this.page++ this.httpRequest() } })

完整代码

      
        import httpManager, { RequestMethod } from '../../utils/requestHttp';
      
      
        import measure from '@ohos.measure'
      
      
        import router from '@ohos.router';
      
      
        
          
type ResultType = { id: number; title: string; userName: string; userPic: string; coverUrl: string; playUrl: string; duration: string; }
interface resultBean { code: number, message: string, result: { total: number, list: Array<ResultType> }, }
@Entry @Component export struct VideoPlay { scroller: Scroller = new Scroller() private videoController: VideoController = new VideoController() @State total: number = 0 @State listData: Array<ResultType> = [] private url: string = "https://api.apiopen.top/api/getHaoKanVideo?size=10"; private page: number = 0
private httpRequest() { httpManager.getInstance() .request({ method: RequestMethod.GET, url: `${this.url}&page=${this.page}` //公开的API }) .then((res: resultBean) => { this.listData = [...this.listData, ...res.result.list]; this.total = res.result.total; this.duration = 0; this.rotateAngle = 0; }) .catch((err) => { console.error(JSON.stringify(err)); }); }
aboutToAppear() { this.httpRequest() }
@State currentPlayIndex: number = 0 @State playUrl: string = '' @State coverUrl: string = '' @State imageDefault: any = $rawfile('noData.svg')
@State offsetY: number = 0 private lastMoveY: number = 0
playNext() { const currentItem = this.listData[this.currentPlayIndex + 1] this.currentPlayIndex = this.currentPlayIndex + 1; this.coverUrl = currentItem?.coverUrl; this.playUrl = currentItem?.playUrl; this.videoController.start() this.scroller.scrollToIndex(this.currentPlayIndex - 1)
if(this.listData.length - this.currentPlayIndex < 3) { this.page = this.page++ this.httpRequest() } }
playPre() { const currentItem = this.listData[this.currentPlayIndex - 1] this.currentPlayIndex = this.currentPlayIndex +- 1; this.coverUrl = currentItem?.coverUrl; this.playUrl = currentItem?.playUrl; this.videoController.start() this.scroller.scrollToIndex(this.currentPlayIndex - 2) }
private title: string = 'Harmony短视频'; @State screnWidth: number = 0; @State screnHeight: number = 0; @State textWidth : number = measure.measureText({ //要计算的文本内容,必填 textContent: this.title, }) @State rotateAngle: number = 0; @State duration: number = 0;
private isDone: boolean = false
@State isPlay: boolean = true
build() { Stack({ alignContent: Alignment.TopEnd }) { Row() { Stack({ alignContent: Alignment.TopStart }) { Button() { Image($r('app.media.ic_public_arrow_left')).width(28).height(28).margin({ left: 6, top: 3, bottom: 3 }) }.margin({ left: 12 }).backgroundColor(Color.Transparent) .onClick(() => { router.back() }) Text(this.title).fontColor(Color.White).fontSize(18).margin({ top: 6 }).padding({ left: (this.screnWidth - this.textWidth / 3) / 2 })
Image($r('app.media.ic_public_refresh')).width(18).height(18) .margin({ left: this.screnWidth - 42, top: 8 }) .rotate({ angle: this.rotateAngle }) .animation({ duration: this.duration, curve: Curve.EaseOut, iterations: 1, playMode: PlayMode.Normal }) .onClick(() => { this.duration = 1200; this.rotateAngle = 360; this.page = 0; this.listData = []; this.httpRequest(); }) } } .width('100%') .height(60) .backgroundColor(Color.Black) .alignItems(VerticalAlign.Center)
if(this.playUrl) { Column() { Text('') } .backgroundColor(Color.Black) .zIndex(997) .width('100%') .height('100%') if(!this.isPlay) { Image($r('app.media.pause')).width(46).height(46) .margin({ right: (this.screnWidth - 32) / 2, top: (this.screnHeight - 32) / 2 }) .zIndex(1000) .onClick(() => { this.isPlay = true this.videoController.start() }) }
Image($rawfile('close.png')).width(32).height(32).margin({ top: 24, right: 24 }) .zIndex(999) .onClick(() => { this.videoController.stop() this.playUrl = '' }) Video({ src: this.playUrl, previewUri: this.coverUrl, controller: this.videoController }) .zIndex(998) .width('100%') .height('100%') .borderRadius(3) .controls(false) .autoPlay(true) .offset({ x: 0, y: `${this.offsetY}px` }) .onFinish(() => { this.playNext() }) .onClick(() => { this.isPlay = false this.videoController.stop() }) .onTouch((event) => { switch (event.type) { case TouchType.Down: // 手指按下 // 记录按下的y坐标 this.lastMoveY = event.touches[0].y break; case TouchType.Up: // 手指按下 this.offsetY = 0 this.isDone = false break; case TouchType.Move: // 手指移动 const offsetY = (event.touches[0].y - this.lastMoveY) * 3; let isDownPull = offsetY < -80 let isUpPull = offsetY > 80 this.lastMoveY = event.touches[0].y if(isUpPull || isDownPull) { this.offsetY = offsetY this.isDone = true }
console.log('=====offsetY======', this.offsetY, isDownPull, isUpPull)
if (isDownPull && this.isDone) { this.playNext() } if (isUpPull && this.isDone) { this.playNext() } break; } }) } List({ scroller: this.scroller, space: 12 }) { ForEach(this.listData, (item: ResultType, index: number) => { ListItem() { Stack({ alignContent: Alignment.TopStart }) { Row() { Image(item.userPic).width(46).height(46).borderRadius(12).margin({ right: 12 }).padding(6) Text(item.title || '标题').fontColor(Color.White).width('80%') } .width('100%') .backgroundColor('#000000') .opacity(0.6) .alignItems(VerticalAlign.Center) .zIndex(9)
Image(item.coverUrl) .width('100%') .height(320) .alt(this.imageDefault)
Row() { Image($rawfile('play.png')).width(60).height(60) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .opacity(0.8) .zIndex(100) .onClick(() => { this.currentPlayIndex = index; this.coverUrl = item.coverUrl; this.playUrl = item.playUrl; this.videoController.start() }) } .width('100%') .height(320) } .padding({ left: 6, right: 6, bottom: 6 }) }) } .width('100%') .margin(6) .position({ y: 66 }) .divider({ strokeWidth: 1, color: 'rgb(247,247,247)', startMargin: 60, endMargin: 0 }) .onScrollIndex((start, end) => { console.log('============>', start, end) if(this.listData.length - end < 3) { this.page = this.page++ this.httpRequest() } }) } .onAreaChange((_oldValue: Area, newValue: Area) => { this.screnWidth = newValue.width as number; this.screnHeight = newValue.height as number; }) } }


浏览 8
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报