#创作者激励#OpenHarmony仿视频播放器应用-爱电影

时间:2021-08-16 01:06:54

【本文正在参加2023年第一期优质创作者激励计划】 作者:徐金生

说在前面的话

OpenHarmony标准系统已更新到了3.2 beta5的版本,近期社区应该也会推出3.2 release版本,甚是期待。富设备上不仅可以拍照、录制视频,娱乐功能必不可少,追剧看电影、打游戏,当前环境下还没有相关的应用可以支持,接下去我就和大家一起从北向角度来实现一个视频播放器,我们暂且叫它—爱电影(别笑话,名字比较俗,但通俗易懂)。

在不同的平台上都存在不同类型的视频播放器,主要的功能就是播放视频资源,根据平台不同其附加的功能也不同,比如在移动设备上,市场上的视频播放器除播放视频外,还给用户提供了选择视频资源高强度、截屏、AI识别、背景音乐搜索、人物搜索等等高质量的体验。本次的目的除了实现视频播放的核心功能外,最主要还是以学习如何在OpenHarmony平台上开发应用为主,所以附加功能待后期增加,下面我将给大家介绍下,爱电影视频播放器的规划。

效果展示

在线视频 #创作者激励#OpenHarmony仿视频播放器应用-爱电影

项目开发

项目规划

项目开始先来看下整体规划图,如下: #创作者激励#OpenHarmony仿视频播放器应用-爱电影

从上面的设计初稿可以看出,我们的爱电影视频播放器有4个页面:闪屏页、电影列表页、播放电影页(包含电影简介)、全屏播放页,接下去我们会对每个页面的具体开发内容和涉及到是知识点进行详细的说明,因为内容较多,会进行多篇的进行讲解,我们开始吧~。

这边插一句,视频播放器的整体规划图中有同学可能会问是使用什么设计工具做的草图,对于个人开发者而言,其实选择性很多,而且基础功能都是免费的,我个人使用的是Pixso设计工具,不说它有什么与众不同,只是个人习惯,用得顺手而已,我比较喜欢Pixso中提供的插件,比如图标插件,支持iconfont直接导入资源,方便导出,开发中需要使用到的图标基本都能找到。

下面简单的介绍下,各页面直接的关系。 #创作者激励#OpenHarmony仿视频播放器应用-爱电影

  • 闪屏页:应用的入口页面,可以用于初始化一些耗时的操作,默认等待时间3s,3s后自动跳转到首页;
  • 首页:由banner+电影列表组成,banner横向自动轮询播放,电影列表展示热门电影,点击相关的资源跳转到视频播放页;
  • 视频播放页:支持在线视频和本地视频加载与播放功能,同时支持视频的暂停、继续播放、拖拽进度、全屏播放、查看视频简介等功能,点击全屏播放跳转至全屏播放页,当然可以通过页面上的返回键回到上级页面—首页;
  • 全屏播放页:横屏播放视频,支持视频基础操作,播放、暂停、拖拽进度、切换播放倍数、退出全屏播放等功能,点击页面上的返回键或退出全屏播放键即可返回到上级界面—视频播放页,当然返回后横屏切换成竖屏显示。

大概了解了页面的基础功能和页面之间的相互关系,接下来我们就从闪屏页开始详细的介绍下开发此功能所涉及的知识点。

首先我们看下闪屏页面设计图,如下:

#创作者激励#OpenHarmony仿视频播放器应用-爱电影

如上图所示,闪屏页面的开发内容主要包含以下几点: 1、主页上加载一张电影海报,包括爱电影logo; 2、右上角显示3s倒计时,倒计时结束自动跳转到主页; 3、闪屏页面跳转到主页时,需要实现一个翻页效果,要求:闪屏页视图跳转时整体视图呈现淡出且渐渐放大的效果。

开发环境

硬件平台:DAYU2000 RK3568 系统版本:OpenHarmony 3.2 beta5 SDK:9(3.2.10.6) IDE:DevEco Studio 3.1 Beta1 Build Version: 3.1.0.200, built on February 13, 2023

开发

创建工程

说明:通过DevEco Studio创建项目,项目相关配置如下图: #创作者激励#OpenHarmony仿视频播放器应用-爱电影#创作者激励#OpenHarmony仿视频播放器应用-爱电影

程序代码

Splash.ets

/**
 * 闪屏页面
 */
import router from '@ohos.router';
const TAG: string = 'Splash'
@Entry
@Component
struct Splash {
  private timeID: number
  @State curTime: number = 3
  @State opacity1: number = 1
  @State scale1: number = 1
  aboutToAppear() {
    console.info(`${TAG} aboutToAppear`)
    this.timeID = setInterval(() => {
      this.curTime -= 1
      if (this.curTime === 0) {
        this.stopTime()
        this.goMainPage()
      }
    }, 1000)
  }
  stopTime() {
    clearInterval(this.timeID)
  }
  goMainPage() {
    console.info(`${TAG} goMainPage`)
    router.pushUrl({
      url: 'pages/Index'
    })
  }
  aboutToDisappear() {
    this.stopTime()
  }
  build() {
    Stack({
      alignContent: Alignment.TopEnd
    }) {
      Image($r('app.media.splash'))
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Fill)
      Text(this.curTime + 's')
        .fontColor(Color.White)
        .fontSize(12)
        .backgroundColor('#993b394a')
        .padding(10)
        .margin({
          top: 40,
          right: 40
        })
        .border({
          radius: 20
        })
    }
    .width('100%')
    .height('100%')
    .opacity(this.opacity1)
    .scale({
      x: this.scale1,
      y: this.scale1
    })
  }
  pageTransition() {
    PageTransitionExit({ duration: 1200, curve: Curve.Linear,
      type: RouteType.Push })
      .onExit((type: RouteType, progress: number) => {
        console.info(`${TAG} PageTransitionExit onExit type:${type} progress:${progress}`)
        this.opacity1 = 1 - progress
        this.scale1 = 1 + progress
      })
  }
}

开发说明

1、整个页面根组件为Stack,通过堆叠的方式增加Image和Text组件实现界面视图,Image用于加载爱电影宣传图,Text用于显示倒计时的数字; 2、通过setInterval()每间隔1秒执行一次计算,并更改倒计时数值,显示在右上角; 3、3s倒计时结束后,执行goMainPage()跳转到主页; 4、通过pageTransition的PageTransitionExit()实现页面之间的转场动画,页面路由router执行pushUrl时页面会立即跳转到目标页面,而闪屏页是初始页面,为了实现界面淡出和放大的效果,我们除了在闪屏页Splash.ets中实现页面退出动效,还需要在目标页面,也就是首页Index.ets中实现pageTransition的PageTransitionEnter(),此页面实现淡入,执行时长大于Splash.ets的PageTransitionExit(),即可以看出转场效果。

Index.ets
pageTransition() {
  PageTransitionEnter({ duration: 1500,
    type: RouteType.Push,
    curve: Curve.Linear })
    .opacity(this.opacity1)
    .onEnter((type: RouteType, progress: number) => {
      console.info(`${TAG} PageTransitionEnter onEnter type:${type} progress:${progress}`)
      this.opacity1 = progress
    })
}

接下来我们详细的说说主页开发涉及的内容,首先我们来看下主页是设计图,如下: #创作者激励#OpenHarmony仿视频播放器应用-爱电影

简单来说页面分成上下两部分,上半部分是一个横向滚动的banner,下半部分是电影资源的列表,列表中的一行两列均分,每一个资源信息包括:电影资源的宣传图、电影名称、演员、电影亮点。

项目开发

开发环境

硬件平台:DAYU2000 RK3568 系统版本:OpenHarmony 3.2 beta5 SDK:9(3.2.10.6) IDE:DevEco Studio 3.1 Beta1 Build Version: 3.1.0.200, built on February 13, 2023

程序代码

Index.ets

import { VideoDataSource } from '../model/VideoDataSource'
import { VideoData } from '../model/VideoData'
import { MockVideoData } from '../model/MockVideoData'
import router from '@ohos.router';
import { VideoListView } from '../view/VideoListView'
const TAG: string = 'Splash Index'
@Entry
@Component
struct Index {
  @State bannerList: Array<VideoData> = []
  @State videoList: Array<VideoData> = []
  private scrollerForScroll: Scroller = new Scroller()
  @State @Watch('scrollChange') scrollIndex: number = 0
  @State opacity1: number = 0
  aboutToAppear() {
    this.initData()
    router.clear()
  }
  scrollChange() {
    if (this.scrollIndex === 0) {
      this.scrollToAnimation(0, 0)
    } else if (this.scrollIndex === 2) {
      this.scrollToAnimation(0, 300)
    }
  }
  scrollToAnimation(xOffset, yOffset) {
    this.scrollerForScroll.scrollTo({
      xOffset: xOffset,
      yOffset: yOffset,
      animation: {
        duration: 3000,
        curve: Curve.FastOutSlowIn
      }
    })
  }
  initData() {
    this.bannerList = MockVideoData.getBannerList()
    this.videoList = MockVideoData.getVideoList()
  }
  build() {
    Column() {
      Scroll(this.scrollerForScroll) {
        Column() {
          // banner
          Swiper() {
            LazyForEach(new VideoDataSource(this.bannerList), (item: VideoData) => {
              Image(item.image)
                .width('100%')
                .height('100%')
                .border({
                  radius: 20
                })
                .onClick(() => {
                  router.pushUrl({ url: 'pages/Playback',
                    params: {
                      video_data: item
                    } })
                })
                .objectFit(ImageFit.Fill)
            }, item => item.id)
          }
          .width('100%')
          .height(240)
          .itemSpace(20)
          .autoPlay(true)
          .indicator(false)
          .cachedCount(3)
          .margin({
            bottom: 20
          })
          VideoListView({
            videoList: $videoList,
            scrollIndex: $scrollIndex,
            isBlackModule: false
          })
        }.width('100%')
      }
      .scrollBar(BarState.Off)
      .scrollable(ScrollDirection.Vertical)
      .scrollBarColor(Color.Gray)
      .scrollBarWidth(30)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .height('100%')
    .backgroundImage($r('app.media.main_bg'), ImageRepeat.XY)
    .padding(20)
  }
  pageTransition() {
    PageTransitionEnter({ duration: 1500,
      type: RouteType.Push,
      curve: Curve.Linear })
      .opacity(this.opacity1)
      .onEnter((type: RouteType, progress: number) => {
        console.info(`${TAG} PageTransitionEnter onEnter type:${type} progress:${progress}`)
        this.opacity1 = progress
      })
  }
}

开发详解

1、电影数据

界面内容需要通过数据进行加载,目前没有相关的电影云端,我们就先在本地mock出一些电影数据,每个电影数据都应该包含以下属性,我们定义了一个类来表示:VideoData.ets

VideoData.ets

export class VideoData {
  id: string
  name: string // 名称
  describe: string // 描述
  resourceType: string // 资源类型 出品年限 类型
  source:string // 来源
  introduction: string // 介绍
  uri: string | Resource // 资源地址
  image: string | Resource // 资源图片
  actors: User[] //参演者
  heat: number // 热度
  directs:User[] // 导演
  grade:string // 评分
  gradeNumber : string // 参与评分人数
}
export class User{
  id: number
  name: string
  role: string
  icon: string | Resource
}
2、构建数据

温馨提示:电影相关的数据是本地模拟,除了电影名称和电影宣传图相关,其他信息纯属虚构,如果你感兴趣也可以自己构建。

这个很简单,就是根据VideoData所定义的数据,构建出首页需要显示的内容,因为mock的数据都是自定义的,所以这里就帖部分代码,如果你有兴趣可以自行构造,如下所示:

MockVideoData.ets

export class MockVideoData {
  static getVideoList(): Array<VideoData> {
    let data: Array<VideoData> = new Array()
    // 电影
    data.push(this.createVideoDataByImage('铁道英雄', $r('app.media.v1')))
    data.push(this.createVideoDataByImage('沙丘', $r('app.media.v2')))
    data.push(this.createVideoDataByImage('那一夜我给你开过车', $r('app.media.v3')))
    data.push(this.createVideoDataByImage('雷神2', $r('app.media.v4')))
    data.push(this.createVideoDataByImage('大圣归来', $r('app.media.v5')))
    data.push(this.createVideoDataByImage('流浪地球', $r('app.media.v6')))
    data.push(this.createVideoDataByImage('狄仁杰', $r('app.media.v7')))
    data.push(this.createVideoDataByImage('独行月球', $r('app.media.v8')))
    data.push(this.createVideoDataByImage('消失的子弹', $r('app.media.v9')))
    data.push(this.createVideoDataByImage('西游降魔篇', $r('app.media.v10')))
    data.push(this.createVideoDataByImage('激战', $r('app.media.v11')))
    data.push(this.createVideoDataByImage('作妖转', $r('app.media.v12')))
    data.push(this.createVideoDataByImage('灭绝', $r('app.media.v13')))
    data.push(this.createVideoDataByImage('独行月球', $r('app.media.v14')))
    data.push(this.createVideoDataByImage('超人·素人特工', $r('app.media.v15')))
    data.push(this.createVideoDataByImage('战狼2', $r('app.media.v16')))
    data.push(this.createVideoDataByImage('四大名捕', $r('app.media.v17')))
    data.push(this.createVideoDataByImage('无人区', $r('app.media.v18')))
    data.push(this.createVideoDataByImage('邪不压正', $r('app.media.v19')))
    return data
  }
  private static createVideoDataByImage(_name, _image, uri?): VideoData {
    if (typeof (uri) === 'undefined') {
      uri = $rawfile('video_4.mp4')
    }
    return this.createVideoData(
      _name,
      '硬汉强力回归',
      '2023 / 动作 / 枪战',
      '爱电影',
      '《邪不压正》是由姜文编剧并执导,姜文、彭于晏、廖凡、周韵、许晴、泽田谦也等主演的动作喜剧电影。该片改编自张北海小说《侠隐》。讲述在1937年\“七七事变\”爆发之前,北平城的“至暗时刻”,一个身负大恨、自美归国的特工李天然,在国难之时涤荡重重阴谋上演的一出终极复仇记。',
      uri,
      _image
    )
  }
  private static createVideoData(_name, _describe, _resourceType, _source, _introduction, _uri, _image,): VideoData {
    let vData: VideoData = new VideoData()
    vData.id = UUIDUtils.getUUID()
    vData.name = _name
    vData.describe = _describe
    vData.resourceType = _resourceType
    vData.source = _source
    vData.introduction = _introduction
    vData.uri = _uri
    vData.image = _image
    vData.actors = []
    let user1: User = new User()
    user1.name = '吴京'
    user1.role = '饰 吴晓晓'
    user1.icon = $r('app.media.actor_02')
    vData.actors.push(user1)
    let user2: User = new User()
    user2.name = '屈楚萧'
    user2.role = '饰 吴晓晓'
    user2.icon = $r('app.media.actor_03')
    vData.actors.push(user2)
    let user3: User = new User()
    user3.name = '吴京'
    user3.role = '饰 吴晓晓'
    user3.icon = $r('app.media.actor_02')
    vData.actors.push(user3)
    vData.heat = 89
    vData.grade = '8.6'
    vData.gradeNumber = '3.6万'
    vData.directs = []
    for (let i = 0; i < 1; i++) {
      let user: User = new User()
      user.name = '戴维'
      user.role = '导演'
      user.icon = $r('app.media.actor_01')
      vData.directs.push(user)
    }
    return vData
  }
  
  
 static getBannerList(): Array<VideoData> {
    let data: Array<VideoData> = new Array()
    // 构建banner数据,与构建videoData类似
    return data
    }
  }
3、banner

在Index.ets的aboutToAppear()函数中初始化数据,通过MockVideoData.getBannerList()获取到banner列表,使用Swiper滑块组件实现自动轮播显示。在Swiper容器中使用了LazyForEach懒加载的方式进行子项的加载。 简单说明下LazyForEach懒加载机制,由于在长列表渲染中会涉及到大量的数据加载,如果处理不当会导致资源占用影响性能,在ArkUI3.0针对这样的情况提供了一种懒加载机制,它会自动根据具体的情况计算出适合渲染的数据,实现数据的按需加载,提升UI刷新效率。

4、电影列表

在Index.ets的aboutToAppear()函数中初始化数据,通过MockVideoData.getVideoList()获取到Video列表,因为电影列表的布局在项目中其他模块也会使用到,所以这里将电影列表抽象出一个子组件VideoListView。

VideoListView.ets

/**
 * 视频列表
 */
import { VideoData } from '../model/VideoData'
import { VideoDataSource } from '../model/VideoDataSource'
import { VideoDataUtils } from '../utils/VideoDataUtils'
import router from '@ohos.router';
const TAG: string = 'VideoListView'
@Component
export struct VideoListView {
  private scrollerForGrid: Scroller = new Scroller()
  @Link videoList: Array<VideoData>
  @Link scrollIndex: number
  @Prop isBlackModule: boolean //是否为黑色模式
  build() {
    // 电影列表
    Grid(this.scrollerForGrid) {
      LazyForEach(new VideoDataSource(this.videoList), (item: VideoData) => {
        GridItem() {
          Column() {
            Image(item.image)
              .width(200)
              .height(250)
              .objectFit(ImageFit.Cover)
              .border({
                width: this.isBlackModule ? 0 : 1,
                color: '#5a66b1',
                radius: 10
              })
            Text(item.name)
              .width(200)
              .height(20)
              .fontColor(this.isBlackModule ? Color.Black : Color.White)
              .fontSize(16)
              .maxLines(1)
              .textOverflow({
                overflow: TextOverflow.Ellipsis
              })
              .margin({
                top: 10
              })
            Text(VideoDataUtils.getUser(item.actors))
              .width(200)
              .height(20)
              .fontColor(this.isBlackModule ? $r('app.color.name_black') : $r('app.color.name_grey'))
              .fontSize(12)
              .maxLines(1)
              .textOverflow({
                overflow: TextOverflow.Ellipsis
              })
            Text(item.describe)
              .width(200)
              .height(20)
              .fontColor(this.isBlackModule ? $r('app.color.describe_black') : $r('app.color.describe_grey'))
              .fontSize(12)
              .maxLines(1)
              .textOverflow({
                overflow: TextOverflow.Ellipsis
              })
          }.width('100%')
          .margin({
            bottom: 10
          })
          .onClick(() => {
            router.pushUrl({ url: 'pages/Playback',
              params: {
                video_data: item
              } }, router.RouterMode.Single)
          })
        }
      }, item => item.id)
    }
    .columnsTemplate('1fr 1fr')
    .columnsGap(10)
    .editMode(true)
    .cachedCount(6)
    .width('100%')
    .height('100%')
    .border({
      width: 0,
      color: Color.White
    })
    .onScrollIndex((first: number) => {
      console.info(`${TAG} onScrollIndex ${first}`)
      this.scrollIndex = first
      if (first === 0) {
        this.scrollerForGrid.scrollToIndex(0)
      }
    })
  }
}

使用Grid实现电影列表,由于电影列表属于长列表数据,所以这里也使用了LazyForEach懒加载机制进行item子项的加载,最终通过import的方式引入到Index.ets页面中,并在布局中添加此组件。

5、滚动页面

首页是电影列表页,需要加载banner和电影列表,所以整体页面都需要可滚动,因此在banner和视频列表容器外添加了Scroll组件

问题1:Scroll与Grid列表嵌套时,电影列表无法显示完整,或者无法显示banner

如下所示: #创作者激励#OpenHarmony仿视频播放器应用-爱电影

为了描述清楚这个问题,我们将界面可以触发滑动的区域分为banner部分和VideoList部分,根据滑动的触发区域不同,进行如下说明: #创作者激励#OpenHarmony仿视频播放器应用-爱电影 1、触发滑动区域在VideoList,当滑动到VideoList末尾时会出现最后一列的item只显示了部分,滑动区域在VideoList的时候无论怎么向上滑动都无法显示完整; 2、在1的场景下,触发滑动区域在banner,并向上滑动,此时可以看到,页面整体向上移动,VideoList中缺失的item部分可以正常显示,banner划出界面时,VideoList可以显示完整; 3、在2的场景下,整个界面目前都是VideoList区域,VideoList已滑动到的最后,此时向下滑动,因为触发的区域是VideoList,所以整个VideoList向下滑动显示,直到电影列表首项,由于整个页面的可滑动区域都是VideoLIst,无法在触发Scroll的滑动,所以banner无法显示。

这个问题其实就是界面视图高度计算和触发滑动监听被消费后无法再向上层传递导致,解决这个问题有多种方式,下面我介绍其中一种。

解决方案Scroll组件中可以添加一个Scroller滑动组件的控制器,控制器可以控制组件的滚动,比如滚动的指定高度,或者指定的index,在Grid中也可以添加一个Scroller控制器进行列表高度控制,在Grid还可以通过onScrollIndex()事件监听网格显示的起始位置item发生变化,返回当前的item坐标。当滑动区域在VideoList时,如果item坐标发生了变化,就更新scrollIndex,在Index.ets中监听scrollIndex的变化,当scrollIndex=0时表示已经滑动到VideoList首项,此时再向下滑动时控制Scroll的控制器,让Scroll滑动到(0,0)位置,也就是页面顶部,这样就可以显示banner;当scrollIndex=2时,表示VideoList向上滑动到第二列,此时设置外层Scroll容器的滑动高度,让banner划出界面,使得VideoList可以完整显示。

实现核心代码
1、Index.ets

import { VideoDataSource } from '../model/VideoDataSource'
import { VideoData } from '../model/VideoData'
import { MockVideoData } from '../model/MockVideoData'
import router from '@ohos.router';
import { VideoListView } from '../view/VideoListView'
const TAG: string = 'Splash Index'
@Entry
@Component
struct Index {
  private scrollerForScroll: Scroller = new Scroller()
  @State @Watch('scrollChange') scrollIndex: number = 0

  scrollChange() {
    if (this.scrollIndex === 0) {
      this.scrollToAnimation(0, 0)
    } else if (this.scrollIndex === 2) {
      this.scrollToAnimation(0, 300)
    }
  }
  scrollToAnimation(xOffset, yOffset) {
    this.scrollerForScroll.scrollTo({
      xOffset: xOffset,
      yOffset: yOffset,
      animation: {
        duration: 3000,
        curve: Curve.FastOutSlowIn
      }
    })
  }

  build() {
    Column() {
      Scroll(this.scrollerForScroll) {
        Column() {
          // banner
          VideoListView({
            videoList: $videoList,
            scrollIndex: $scrollIndex,
            isBlackModule: false
          })
        }.width('100%')
      }
      .scrollBar(BarState.Off)
      .scrollable(ScrollDirection.Vertical)
      .scrollBarColor(Color.Gray)
      .scrollBarWidth(30)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .height('100%')
    .backgroundImage($r('app.media.main_bg'), ImageRepeat.XY)
    .padding(20)
  }

}

2、VideoListView.ets
/**
 * 视频列表
 */
import { VideoData } from '../model/VideoData'
import { VideoDataSource } from '../model/VideoDataSource'
import { VideoDataUtils } from '../utils/VideoDataUtils'
import router from '@ohos.router';

const TAG: string = 'VideoListView'

@Component
export struct VideoListView {
  private scrollerForGrid: Scroller = new Scroller()
  @Link scrollIndex: number

  build() {
    // 电影列表
    Grid(this.scrollerForGrid) {
      LazyForEach(new VideoDataSource(this.videoList), (item: VideoData) => {
        GridItem() {
         // item
          }.width('100%')
          .margin({
            bottom: 10
          })
          .onClick(() => {
            router.pushUrl({ url: 'pages/Playback',
              params: {
                video_data: item
              } }, router.RouterMode.Single)
          })
        }

      }, item => item.id)
    }
    .columnsTemplate('1fr 1fr')
    .columnsGap(10)
    .editMode(true)
    .cachedCount(6)
    .width('100%')
    .height('100%')
    .border({
      width: 0,
      color: Color.White
    })
    .onScrollIndex((first: number) => {
      console.info(`${TAG} onScrollIndex ${first}`)
      this.scrollIndex = first
      if (first === 0) {
        this.scrollerForGrid.scrollToIndex(0)
      }
    })
  }
}

主页上显示了电影资源,点击你想看的电影会跳转至电影播放页面,接下来我们详细的说说电影播放页面开发涉及的内容,首先我们来看下电影播放页面的设计图,如下:

#创作者激励#OpenHarmony仿视频播放器应用-爱电影#创作者激励#OpenHarmony仿视频播放器应用-爱电影

从上图我们知道,从结构上来讲可以分为上下两部分组成,上部分是视频播放器,下部分是电影简介。 视频播放器:由前后两层,底层是视频播放,顶层是视频播放控制器,包括了返回按键、显示视频名称、控制视频的播放、暂停、更新进度、全屏显示、视频总时长和当前播放视频时间点。 电影简介:包括电影的介绍以及一些推荐的电影,点击“简介”弹窗显示该电影的详细信息,包括:电影的类型、来源、评分、热度、演员、详细的剧情等。

项目开发

开发环境

硬件平台:DAYU2000 RK3568 系统版本:OpenHarmony 3.2 beta5 SDK:9(3.2.10.6) IDE:DevEco Studio 3.1 Beta1 Build Version: 3.1.0.200, built on February 13, 2023

程序代码

1、Playback.ets

首先我们看下视频播放页面的代码


import { VideoView } from '../view/VideoView';
import { PLAYBACK_SPEED, PLAYBACK_STATE } from '../model/Playback'
import router from '@ohos.router';
import { VideoListView } from '../view/VideoListView'
import { VideoData } from '../model/VideoData'
import { MockVideoData } from '../model/MockVideoData'
import { VideoDataUtils } from '../utils/VideoDataUtils'
import { VideoIntroduceView } from '../view/VideoIntroduceView'
import { VideoSpeed } from '../model/VideoSpeed'
import emitter from '@ohos.events.emitter';
import { CommonData } from '../model/CommonData'
/**
 * 视频播放页面
 */
const TAG: string = 'Playback'
@Entry
@Component
struct Playback {
  @State mTag: string = TAG
  private name: string
  private introduction: string
  @State uri: any = null
  @State previewImage: any = null
  private actors: string | Resource
  private directs: string | Resource
  @State rateIndex: number = 1
  @State rate: VideoSpeed = PLAYBACK_SPEED[1]
  @State @Watch('scrollChange') scrollIndex: number = 0
  @State likeVideoList: Array<VideoData> = []
  private scrollerForScroll: Scroller = new Scroller()
  @State isShowIntroduce: boolean = false // 是否显示简介
  @State mVideoData: VideoData = null
  @State isScrollClose: boolean = false
  @Provide('play_time') curTime: number = 0
  @State videoState: string = PLAYBACK_STATE.INIT
  @Provide('show_operation') isShowOperation: boolean = true
  aboutToAppear() {
    console.info(`${TAG} aboutToAppear curTime:${this.curTime}`)
    this.initData()
  }
  initData() {
    this.likeVideoList = MockVideoData.getVideoList()
    // 获取当前需要播放的电影资源信息
    this.mVideoData = router.getParams()['video_data']
    this.name = this.mVideoData.name
    this.uri = this.mVideoData.uri
    this.previewImage = this.mVideoData.image
    this.actors = VideoDataUtils.getUser(this.mVideoData.actors)
    this.directs = VideoDataUtils.getUser(this.mVideoData.directs)
    this.introduction = this.mVideoData.introduction
  }
  onCloseIntroduce() {
    this.isShowIntroduce = false
  }
  onScreen(isFull: boolean) {
    console.info(`${TAG} onScreen ${isFull} mVideoData:${JSON.stringify(this.mVideoData)} curTime:${this.curTime} videoState:${this.videoState}`)
    if (isFull) {
      router.pushUrl({
        url: 'pages/FullScreen',
        params: {
          video_data: this.mVideoData,
          cur_time: this.curTime, // 当前播放时间
          video_state: this.videoState // 播放状态
        }
      })
    }
  }
  scrollChange() {
    if (this.scrollIndex === 0) {
      this.scrollToAnimation(0, 0)
    } else if (this.scrollIndex === 2) {
      this.scrollToAnimation(0, 280)
    }
  }
  onPageShow() {
    // 竖屏显示
    emitter.emit({
      eventId: CommonData.EVENT_WINDOW_PORTRAIT_ID
    })
  }
  onPageHide() {
    console.info(`${TAG} onPageHide`)
  }
  scrollToAnimation(xOffset, yOffset) {
    this.scrollerForScroll.scrollTo({
      xOffset: xOffset,
      yOffset: yOffset,
      animation: {
        duration: 3000,
        curve: Curve.FastOutSlowIn
      }
    })
  }
  build() {
    Stack() {
      Column() {
        Stack({
          alignContent: Alignment.TopStart
        }) {
          VideoView({
            _TAG: this.mTag,
            videoUri: $uri,
            previewUri: $previewImage,
            videoRate: $rate,
            videoRateIndex: $rateIndex,
            onScreen: this.onScreen.bind(this),
            isFullScreen: false,
            videoState: $videoState,
            isEvent: true,
            mWidth: '100%',
            mHeight: '100%'
          })
            .margin({
              top: 15,
              bottom: 15
            })
          if (this.isShowOperation) {
            Row({ space: 10 }) {
              Image($r('app.media.icon_back'))
                .width(24)
                .height(24)
                .objectFit(ImageFit.Cover)
                .onClick(() => {
                  router.back()
                })
              Text(this.name)
                .fontSize(20)
                .fontColor(Color.White)
            }
            .padding(20)
          }
        }.width('100%')
        .height('40%')
        .backgroundColor(Color.Black)
        // 介绍
        Column() {
          Scroll(this.scrollerForScroll) {
            Column() {
              // 简介内容
              Column() {
                // 标题
                Row() {
                  Text(this.name)
                    .fontColor(Color.Black)
                    .fontSize(26)
                    .width('88%')
                  Row() {
                    Text($r('app.string.introduce'))
                      .fontColor($r('app.color.introduce_text'))
                      .fontSize(16)
                    Image($r('app.media.icon_right'))
                      .width(16)
                      .height(20)
                      .objectFit(ImageFit.Contain)
                  }.onClick(() => {
                    console.info(`CLICK 设置前 isShowIntroduce ${this.isShowIntroduce}`)
                    this.isScrollClose = false
                    this.isShowIntroduce = true
                    console.info(`CLICK 设置后 isShowIntroduce ${this.isShowIntroduce}`)
                  })
                }
                .justifyContent(FlexAlign.Start)
                .alignItems(VerticalAlign.Bottom)
                .width('100%')
                .height('40')
                .border({
                  width: 0,
                  color: Color.Gray
                })
                // 简介
                Column() {
                  Row({ space: 15 }) {
                    Text($r('app.string.directs'))
                      .fontColor($r('app.color.introduce_title_text'))
                      .fontSize(16)
                    Text(this.directs)
                      .fontColor($r('app.color.introduce_text'))
                      .fontSize(14)
                  }.justifyContent(FlexAlign.Start)
                  .width('100%')
                  .margin({
                    top: 5,
                    bottom: 5
                  })
                  Text($r('app.string.actors'))
                    .fontColor($r('app.color.introduce_title_text'))
                    .fontSize(16)
                    .margin({
                      top: 5,
                      bottom: 5
                    })
                    .width('100%')
                  Text(this.actors)
                    .fontColor($r('app.color.introduce_text'))
                    .fontSize(14)
                    .width('100%')
                    .margin({
                      top: 5,
                      bottom: 5
                    })
                  Text(this.introduction)
                    .fontColor($r('app.color.introduce_text'))
                    .fontSize(16)
                    .width('100%')
                    .lineHeight(26)
                    .maxLines(2)
                    .textOverflow({
                      overflow: TextOverflow.Ellipsis
                    })
                    .margin({
                      top: 5,
                      bottom: 5
                    })
                }
                .width('100%')
                .height(150)
                .justifyContent(FlexAlign.Start)
                .margin({
                  top: 20,
                  bottom: 20
                })
                .border({
                  width: 0,
                  color: Color.Green
                })
                Text($r('app.string.guess_like'))
                  .fontColor(Color.Black)
                  .fontSize(18)
                  .width('100%')
              }
              .width('100%')
              .height('280')
              .border({
                width: 0,
                color: Color.Red
              })
              // 猜你喜欢
              VideoListView({
                videoList: $likeVideoList,
                scrollIndex: $scrollIndex,
                isBlackModule: true
              })
            }
          }
          .scrollBar(BarState.Off)
        }
        .width('95%')
        .height('55%')
        .backgroundColor(Color.White)
        .padding(20)
        .margin(30)
        .border({
          radius: 20
        })
      }.width('100%')
      .height('100%')
      // 电影简介弹窗
      Panel(this.isShowIntroduce) {
        VideoIntroduceView({
          videoData: $mVideoData,
          onClose: this.onCloseIntroduce.bind(this),
          isScrollClose: this.isScrollClose
        })
      }
      .type(PanelType.Foldable) //  内容永久展示
      .mode(PanelMode.Half)
      .dragBar(false)
      .halfHeight(500)
      .onChange((width, height, mode) => {
        console.info(`${TAG} Panel onChange ${JSON.stringify(mode)}`)
        if (mode === PanelMode.Mini) {
          this.isShowIntroduce = false
          this.isScrollClose = true
        }
      })
    }
    .width('100%')
    .height('100%')
    .backgroundImage($r('app.media.main_bg'), ImageRepeat.XY)
  }
}

  • VideoView:抽象出的视频播放组件,用于播放视频和控制视频
  • 电影简介,外层使用Scroll封装,其中包含了简介的基础信息和推荐电影列表,由于外层是Scroll,电影推荐列表使用了Grid组件,存在滑动冲突的问题,解决方案参看:OpenHarmony仿视频播放器应用-爱电影(二) 中的 “ 问题1:Scroll与Grid列表嵌套时,电影列表无法显示完整,或者无法显示banner”。
  • 电影简介详情使用Panel容器封装,Panel为可滑动面板,显示时从底部向上滑起,类似于抽屉组件。
  • VideoIntroduceView:自定义封装的一个电影介绍的容器,容器内部包括了详细的电影简介。
  • VideoListView:猜你喜欢的模块中,使用了电影列表控件,这个在上一篇也有提到。
2、VideoView.ets

视频播放器组件,主要使用Video媒体容器组件实现,Video不仅可以加载本地资源,也可以加载网络资源,在加载网络资源时,首先需要在module.json5中添加ohos.permission.INTERNET权限,并且连接外网,然后只需要替换Video的src属性值为网络地址即可。


/**
 * 视频播放器视图
 */
import { PLAYBACK_SPEED, PLAYBACK_STATE } from '../model/Playback'
import { TimeUtils } from '../utils/TimeUtils'
import { VideoSpeed } from '../model/VideoSpeed'
import emitter from '@ohos.events.emitter';
import { CommonData } from '../model/CommonData'
const TAG: string = 'VideoView'
@Component
export struct VideoView {
  @Prop _TAG: string
  @Link videoUri: any
  @Link previewUri: any
  @Link videoRate: VideoSpeed
  @Link videoRateIndex: number
  @Prop mWidth: string
  @Prop mHeight: string
  private videoController: VideoController = new VideoController()
  @Link videoState: string
  @Consume('play_time') curTime: number
  @State curTimeStr: string = '00:00:00'
  @State durationCountStr: string = '00:00:00'
  @State curSliderValue: number = 0
  private durationNumber: number = 0
  private selectSpeedOption: Array<SelectOption>
  onScreen: (isFull: boolean) => void
  @Prop isFullScreen: boolean
  @State isAutoPlay: boolean = false // 是否自动播放
  private lastTime: number
  isEvent: boolean // 是否需要注册事件
  @Consume('show_operation') isShowOperation: boolean // 是否显示操作视图
  private timeID: number
  aboutToAppear() {
    console.info(`${TAG} ${this._TAG} aboutToAppear`)
    if (this.isEvent) {
      this.registerEmitter()
    }
    this.startTime()
    // 初始化播放倍数
    this.selectSpeedOption = new Array<SelectOption>()
    for (const item of PLAYBACK_SPEED) {
      let option: SelectOption = {
        value: item.val
      }
      this.selectSpeedOption.push(option)
    }
    this.updateVideo(this.curTime, this.videoState, 'aboutToAppear')
  }
  updateVideo(time: number, state: string, tag: string) {
    console.info(`${TAG} ${this._TAG} ${tag} updateVideo time: ${time} state: ${state}`)
    if (state === PLAYBACK_STATE.START) {
      console.info(`${TAG} ${this._TAG}  updateVideo start`)
      this.isAutoPlay = true
      this.lastTime = time
      this.vSetTime(time)
      //      this.videoController.start()
    } else {
      console.info(`${TAG} ${this._TAG}  updateVideo stop`)
      this.isAutoPlay = false
      this.videoController.stop()
    }
  }
  aboutToDisappear() {
    console.info(`${TAG} ${this._TAG}  aboutToDisappear`)
    this.destroy()
  }
  registerEmitter() {
    emitter.on({
      eventId: CommonData.EVENT_PLAY_VIDEO
    }, (event) => {
      console.info(`${TAG} ${this._TAG} ${CommonData.EVENT_PLAY_VIDEO} callback : ${JSON.stringify(event)}`)
      let params = event.data
      if (params.hasOwnProperty('cur_time')) {
        this.lastTime = params['cur_time']
        console.info(`${TAG} ${this._TAG} Emitter getParams curTime: ${this.curTime}`)
      }
      if (params.hasOwnProperty('video_state')) {
        this.videoState = params['video_state']
        console.info(`${TAG} ${this._TAG} Emitter getParams curTime: ${this.videoState}`)
      }
      console.info(`${TAG} ${this._TAG} Emitter getParams curTime: ${this.videoState}`)
      this.updateVideo(this.lastTime, this.videoState, 'emitter')
    })
  }
  unregisterEmitter() {
    emitter.off(CommonData.EVENT_PLAY_VIDEO)
  }
  vSetTime(time: number) {
    console.info(`${TAG} ${this._TAG}  vSetTime curTime: ${time}`)
    this.videoController.setCurrentTime(time, SeekMode.Accurate)
  }
  clickStartOrPause() {
    if (this.videoState === PLAYBACK_STATE.START) {
      this.videoController.pause()
    } else {
      this.videoController.start()
    }
  }
  startTime() {
    if (this.timeID > 0) {
      this.stopTime()
    }
    this.timeID = setTimeout(() => {
      this.isShowOperation = false
    }, 5000)
  }
  stopTime() {
    clearTimeout(this.timeID)
    this.timeID = -1
  }
  destroy() {
    this.videoController.stop()
    if (this.isEvent) {
      this.unregisterEmitter()
    }
    this.stopTime()
  }
  build() {
    Stack({
      alignContent: Alignment.BottomStart
    }) {
      // 视频播放
      Video({
        src: 'https://vd4.bdstatic.com/mda-jdmyw860sqcu8utw/sc/mda-jdmyw860sqcu8utw.mp4',
        previewUri: this.previewUri,
        currentProgressRate: this.videoRate.speed,
        controller: this.videoController
      })
        .width('100%')
        .backgroundColor('#000000')
        .controls(false)
        .autoPlay(this.isAutoPlay)
        .objectFit(ImageFit.Contain)
        .onTouch((event) => {
          if (event.type === TouchType.Down) {
            console.info(`${TAG} ${this._TAG} 视频被点击`)
            this.isShowOperation = !this.isShowOperation
            if (this.isShowOperation) {
              this.startTime()
            }
          }
        })
        .onStart(() => {
          console.info(`${TAG} ${this._TAG} 播放`)
          this.videoState = PLAYBACK_STATE.START
        })
        .onPause(() => {
          console.info(`${TAG} ${this._TAG} 暂停`)
          this.videoState = PLAYBACK_STATE.PAUSE
        })
        .onFinish(() => {
          console.info(`${TAG} ${this._TAG} 结束`)
          this.videoState = PLAYBACK_STATE.FINISH
        })
        .onError(() => {
          console.info(`${TAG} ${this._TAG} 播放失败`)
          this.videoState = PLAYBACK_STATE.ERROR
        })
        .onPrepared((callback) => {
          // 	视频准备完成时触发该事件,通过duration可以获取视频时长,单位为秒(s)
          this.durationNumber = callback.duration
          this.durationCountStr = TimeUtils.FormatTime(this.durationNumber)
          //          console.info(`${TAG} onPrepared 视频时长 ${this.durationCountStr} 原始值:${this.durationNumber}`)
        })
        .onSeeking((callback) => {
          // 操作进度条过程时上报时间信息,单位为s。
          console.info(`${TAG} ${this._TAG} onSeeking ${callback.time}`)
        })
        .onUpdate((callback) => {
          // 播放进度变化时触发该事件,单位为s,更新时间间隔为250ms。
          if (this.lastTime > 0 && callback.time !== this.lastTime) {
            console.info(`${TAG} ${this._TAG} onUpdate vSetTime curTime lastTime ${this.lastTime} callback time:${callback.time}`)
            this.vSetTime(this.lastTime)
            this.curTime = this.lastTime
            this.lastTime = 0
          } else {
            this.curTime = callback.time
            console.info(`${TAG} ${this._TAG} onUpdate curTime ${this.curTime}`)
          }
          this.curTimeStr = TimeUtils.FormatTime(this.curTime)
          //          console.info(`${TAG} onUpdate 视频播放时间更新 ${this.curTimeStr} 原始值${callback.time}`)
          this.curSliderValue = TimeUtils.Rounding(this.curTime * 100 / this.durationNumber)
          //          console.info(`${TAG} onUpdate 更新滑块进度 ${this.curSliderValue}`)
        })
        .onFullscreenChange((callback) => {
          // 在全屏播放与非全屏播放状态之间切换时触发该事件,返回值为true表示进入全屏播放状态,为false则表示非全屏播放。
          console.info(`${TAG} ${this._TAG} onFullscreenChange ${callback.fullscreen}`)
        })
      if (this.isShowOperation) {
        // 居中操作按钮(播放/暂停)
        Column() {
          Image(this.videoState !== PLAYBACK_STATE.START ? $r('app.media.video_start_60') : $r('app.media.video_pause_60'))
            .width(60)
            .height(60)
            .objectFit(ImageFit.Cover)
            .onClick(() => {
              this.clickStartOrPause()
            })
        }
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .width('100%')
        .height('100%')
        // 视频操作栏
        Row({
          space: 10
        }) {
          // 播放/暂停按钮
          Image(this.videoState !== PLAYBACK_STATE.START ? $r('app.media.video_start') : $r('app.media.video_pause'))
            .width(26)
            .height(26)
            .objectFit(ImageFit.Cover)
            .onClick(() => {
              this.clickStartOrPause()
            })
          // 播放时间
          Text(this.curTimeStr)
            .fontSize(10)
            .fontColor(Color.White)
          // 进度条
          Slider({
            value: this.curSliderValue,
            min: 0,
            max: 100,
            style: SliderStyle.OutSet
          })
            .showSteps(false)
            .showTips(false)
            .blockColor(Color.White)
            .trackColor(Color.White)
            .selectedColor('#36AD08')
            .width('50%')
            .onChange((value: number, mode: SliderChangeMode) => {
              // SliderChangeMode Begin=0:开始 Moving=1 End=2 Click=3
              let timePercentage = value.toFixed(0)
              console.info(`${TAG} Slider onChange ${value} ${timePercentage}`)
              // 计算滑动滑块需要播放的时间
              this.curTime = parseInt(timePercentage) * this.durationNumber / 100
              this.vSetTime(this.curTime)
              this.curTimeStr = TimeUtils.FormatTime(this.curTime)
              console.info(`${TAG} ${this._TAG}  Slider onChange 滑块滑动时间变更 curTime:${this.curTime} curTimeStr ${this.curTimeStr}`)
            })
          // 总时长
          Text(this.durationCountStr)
            .fontSize(10)
            .fontColor(Color.White)
          Blank()
          // 播放倍数
          if (this.isFullScreen) {
            Select(this.selectSpeedOption)
              .selected(this.videoRateIndex)
              .value(this.videoRate.val)
              .font({ size: 10 })
              .fontColor(Color.White)
              .selectedOptionFont({ size: 10 })
              .selectedOptionFontColor('#F54F02')
              .optionFontColor('#5E5E5E')
              .optionFont({ size: 10 })
              .onSelect((index: number) => {
                console.info('Select:' + index)
                this.videoRate = PLAYBACK_SPEED[index]
                this.videoRateIndex = index
                console.info(`${TAG} videoRateIndex = ${this.videoRateIndex}`)
              })
              .border({
                width: 0,
                color: Color.White
              })
          }
          // 浮动层
          Image($r('app.media.icon_float'))
            .width(32)
            .height(32)
            .objectFit(ImageFit.Cover)
            .onClick(() => {
              console.info(`${TAG} 启动浮动层`)
            })
          // 全屏切换
          Image(this.isFullScreen ? $r('app.media.icon_small_screen') : $r('app.media.icon_full_screen'))
            .width(26)
            .height(26)
            .objectFit(ImageFit.Cover)
            .onClick(() => {
              console.info(`${TAG} 全屏切换`)
              this.onScreen(!this.isFullScreen)
            })
        }
        .width('100%')
        .height('60')
        .backgroundImage($r('app.media.bg_control_1'), ImageRepeat.X)
        .padding({
          left: 20,
          right: 20
        })
      }
    }.width(this.mWidth)
    .height(this.mHeight)
    .backgroundColor(Color.Gray)
  }
}

下面对以上代码进行说明:

  • @Provide() @Consume() :这里的注解@Provide和@Consume,两者需要配合使用。@Provide作为数据的提供方,可以更新其子孙节点的数据,并触发页面渲染。@Consume在感知到@Provide数据的更新后,会触发当前自定义组件的重新渲染。 | @Consume('play_time') curTime:用于向全屏播放时同步当前播放的时间节点,在全屏播放时可以继续播放。 | @Consume('show_operation') isShowOperation: 用于控制播放器上层视图(暂停、播放、进度、时间、最大化)的显示和隐藏。

  • Video中有onStart、onPause、onFinish、onError、onPrepared、onSeeking、onUpdate 函数用于监听视频播放的状态

  • Slider:滑动条组件,用于显示和控制视频播放的进度,用户可以移动滑块来控制视频播放的进度。

  • Select:下拉选择菜单,用于选择视频的倍数,在全屏播放时使用。

3、VideoIntroduceView.ets

详细的电影介绍,包括电影的相关信息,演员列表,演员图片、电影的剧情。


import { VideoData, User } from '../model/VideoData'
/**
 * 电影简介
 */
const TAG: string = 'VideoIntroduceView'
@Component
export struct VideoIntroduceView {
  @Link videoData: VideoData
  onClose: () => void
  @Prop @Watch('scrollClose') isScrollClose: boolean //  是否为滚动关闭
  private userList: Array<User> = []
  private scroller: Scroller = new Scroller()
  aboutToAppear() {
    console.info(`${TAG} aboutToAppear`)
    // 初始化演员数据
    if (this.videoData) {
      // 先添加导演
      for (const item of this.videoData.directs) {
        this.userList.push(item)
      }
      // 再添加演员
      for (const item of this.videoData.actors) {
        this.userList.push(item)
      }
    }
  }
  scrollClose() {
    if (this.isScrollClose) {
      this.scroller.scrollEdge(Edge.Top)
    }
  }
  aboutToDisappear() {
    this.userList = []
  }
  build() {
    Scroll(this.scroller) {
      Column() {
        Row() {
          // 简介标题
          Text($r('app.string.introduce'))
            .fontSize(22)
            .fontColor(Color.Black)
            .width('95%')
          Column() {
            Image($r('app.media.icon_close'))
              .width(22)
              .height(22)
              .objectFit(ImageFit.Cover)
          }.width(32)
          .height(32)
          .onClick(() => {
            console.info(`${TAG} CLOSE onClose`)
            this.scroller.scrollEdge(Edge.Top)
            this.onClose()
          })
        }.justifyContent(FlexAlign.Start)
        .width('100%')
        .margin({
          top: 10,
          bottom: 10
        })
        // 电影基础信息
        Row({ space: 20 }) {
          Image(this.videoData.image)
            .width(105)
            .height(135)
            .objectFit(ImageFit.Cover)
          Column({ space: 10 }) {
            Text(this.videoData.name)
              .fontSize(18)
              .fontColor('#2A1818')
              .align(Alignment.Start)
              .width('300')
            Row() {
              Text(this.videoData.describe)
                .fontSize(14)
                .fontColor(Color.White)
                .backgroundColor('#C4C4C4')
                .padding({
                  top: 5,
                  bottom: 5,
                  left: 10,
                  right: 10
                })
              Blank()
            }.width('300')
            Text(this.videoData.resourceType)
              .fontSize(18)
              .fontColor('#5E5E5E')
              .width(300)
            Row({ space: 5 }) {
              Image($r('app.media.icon_source'))
                .width(16)
                .height(16)
                .objectFit(ImageFit.Cover)
              Text(this.videoData.source)
                .fontSize(18)
                .fontColor('#5E5E5E')
                .width(200)
            }
            .width(300)
            .alignItems(VerticalAlign.Center)
            .justifyContent(FlexAlign.Start)
          }
          .justifyContent(FlexAlign.Start)
        }
        .width('100%')
        .height(135)
        .justifyContent(FlexAlign.Start)
        .margin({
          top: 10,
          bottom: 10
        })
        // 评分
        Row({ space: 20 }) {
          Column({ space: 0 }) {
            Text(this.videoData.grade)
              .fontSize(35)
              .fontColor('#F54F02')
            Text(this.videoData.gradeNumber + '人参与评分')
              .fontSize(12)
              .fontColor('#868686')
          }.width('120')
          Row({ space: 10 }) {
            Image($r('app.media.icon_fire'))
              .width(16)
              .height(16)
              .objectFit(ImageFit.Cover)
            Text($r('app.string.heat_value'))
              .fontSize(14)
              .fontColor('#F54F02')
            Progress({
              value: this.videoData.heat,
              type: ProgressType.Linear,
              total: 100
            })
              .width('40%')
              .color('#F54F02')
              .backgroundColor('#868686')
              .style({
                strokeWidth: 10
              })
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.Start)
        .margin({
          top: 10,
          bottom: 10
        })
        // 演职员
        Text($r('app.string.cast'))
          .fontSize(18)
          .fontColor('#2A1818')
          .width('100%')
          .margin({
            top: 10,
            bottom: 10
          })
        // 演员列表
        Row() {
          List({
            space: 10,
            initialIndex: 0
          }) {
            ForEach(this.userList, (item: User) => {
              ListItem() {
                Column({ space: 10 }) {
                  Image(item.icon)
                    .width(105)
                    .height(135)
                    .objectFit(ImageFit.Cover)
                  Text(item.name)
                    .width(105)
                    .fontSize(14)
                    .fontColor('#2A1818')
                  Text(item.role)
                    .width(105)
                    .fontSize(12)
                    .fontColor('#868686')
                }
              }
            })
          }
          .scrollBar(BarState.Off)
          .listDirection(Axis.Horizontal)
          .cachedCount(3)
        }.width('100%')
        .justifyContent(FlexAlign.Start)
        .margin({
          top: 10,
          bottom: 10
        })
        // 剧情
        Text($r('app.string.story'))
          .fontSize(18)
          .fontColor('#2A1818')
          .width('100%')
          .margin({
            top: 10,
            bottom: 10
          })
        Text(this.videoData.introduction)
          .fontSize(12)
          .fontColor('#868686')
          .margin({
            top: 10,
            bottom: 10
          })
      }
      .padding({
        top: 20,
        bottom: 20,
        left: 40,
        right: 40
      })
      .justifyContent(FlexAlign.Start)
    }.width('100%')
    .scrollBar(BarState.Off)
    .backgroundColor(Color.White)
    .border({
      radius: 20
    })
  }
}

电影简介中并无比较复杂的内容,主要是根据UI设计将各组件进行布置即可,这里主要讲讲电影简介外层的容器—Panel,可滑动面板,是一种用于内容展示的窗口,窗口的尺寸可以切换,根据PanelType的不同,可以分为三种不同类型的屏幕,大(类全屏)、中(类半屏)、小,具体说明如下:

PanelType枚举说明
名称 描述
Minibar 提供minibar和类全屏展示切换效果。
Foldable 内容永久展示类,提供大(类全屏)、中(类半屏)、小三种尺寸展示切换效果。
Temporary 内容临时展示区,提供大(类全屏)、中(类半屏)两种尺寸展示切换效果。

可滑动面板的初始状态有三种:

PanelMode枚举说明
名称 描述
Mini 类型为minibar和foldable时,为最小状态;类型为temporary,则不生效。
Half 类型为foldable和temporary时,为类半屏状态;类型为minibar,则不生效。
Full 类全屏状态。

在这里Panel的type为PanelType.Foldable,mode为PanelMode.Half,也就是初始为半屏显示,同时设置了半屏的高度halfHeight(500)。相关属性详解如下:

属性
名称 参数类型 描述
type PanelType 设置可滑动面板的类型。<br/>默认值:PanelType.Foldable
mode PanelMode 设置可滑动面板的初始状态。
dragBar boolean 设置是否存在dragbar,true表示存在,false表示不存在。<br/>默认值:true
fullHeight string | number 指定PanelMode.Full状态下的高度。
halfHeight string | number 指定PanelMode.Half状态下的高度,默认为屏幕尺寸的一半。
miniHeight string | number 指定PanelMode.Mini状态下的高度。
show boolean 当滑动面板弹出时调用。
backgroundMask<sup>9+</sup> ResourceColor 指定Panel的背景蒙层。

Panel组件向下滑动的时候会进入PanelModeMini状态,此状态中滑块不会完全消失在界面上,而是需要动态的设置Panel构造函数来控制组件的显隐。

Panel(show:boolean)

参数:

参数名 参数类型 必填 参数描述
show boolean 控制Panel显示或隐藏。

那我们如何指导Panel的变化呢,主要是通过Panel的监听事件onChange(),通过回调函数中的PanelMode参数判断当前Panel的状态。

事件
名称 功能描述
onChange(event: (width: number, height: number, mode: PanelMode) => void) 当可滑动面板发生状态变化时触发, 返回的height值为内容区高度值,当dragbar属性为true时,panel本身的高度值为dragbar高度加上内容区高度。

接上一篇,视频播放页面属于小屏显示,为了让观演效果更好,可以选择全屏播放,全屏播放时界面由竖屏转为横屏显示,并且可以双向同步观影时间,无论是从视频播放页面进入全屏播放页面,还是由全屏播放页面返回到视频播放页面,只要处于播放在,就会同步播放时间,在页面切换后继续播放视频。当然,在全屏播放时页面处于横屏,返回到视频播放页面界面则切换回竖屏,我们来看下设计图: #创作者激励#OpenHarmony仿视频播放器应用-爱电影#创作者激励#OpenHarmony仿视频播放器应用-爱电影 从设计图上看,全屏播放页面的布局很简单,我们在上一节总已经将视频播放视图封装成了一个子组件—VideoView.ets,我们只要将其加载到全屏播放页面即可。

项目开发

开发环境

硬件平台:DAYU2000 RK3568 系统版本:OpenHarmony 3.2 beta5 SDK:9(3.2.10.6) IDE:DevEco Studio 3.1 Beta1 Build Version: 3.1.0.200, built on February 13, 2023

程序代码

1、FullScreen.ets

/**
 * 全屏播放
 */
import emitter from '@ohos.events.emitter';
import { CommonData } from '../model/CommonData'
import router from '@ohos.router';
import { VideoView } from '../view/VideoView';
import { VideoData } from '../model/VideoData'
import { VideoDataUtils } from '../utils/VideoDataUtils'
import { VideoSpeed } from '../model/VideoSpeed'
import { PLAYBACK_SPEED, PLAYBACK_STATE } from '../model/Playback'
const TAG: string = 'VideoFullScreen'
@Entry
@Component
struct VideoFullScreen {
  @State mTag: string = TAG
  @State mVideoData: VideoData = null
  private name: string
  @State uri: any = null
  @State previewImage: any = null
  private actors: string | Resource
  private directs: string | Resource
  private introduction: string
  @State videoState: string = PLAYBACK_STATE.INIT
  @Provide('play_time') curTime: number = 0
  @State rateIndex: number = 1
  @State rate: VideoSpeed = PLAYBACK_SPEED[1]
  @Provide('show_operation') isShowOperation : boolean = true
  aboutToAppear() {
    // 横屏显示
    emitter.emit({
      eventId: CommonData.EVENT_WINDOW_LANDSCAPE_ID
    })
    this.initData()
  }
  initData() {
    // 获取当前需要播放的电影资源信息
    this.mVideoData = router.getParams()['video_data']
    this.name = this.mVideoData.name
    this.uri = this.mVideoData.uri
    this.previewImage = this.mVideoData.image
    this.actors = VideoDataUtils.getUser(this.mVideoData.actors)
    this.directs = VideoDataUtils.getUser(this.mVideoData.directs)
    this.introduction = this.mVideoData.introduction
    this.curTime = router.getParams()['cur_time']
    this.videoState = router.getParams()['video_state']
    console.info(`${TAG} curTime:${this.curTime} videoState:${this.videoState}`)
  }
  onBackPress() {
    console.info(`${TAG} onBackPress`)
    this.sendPlayVideo()
  }
  onScreen(isFull: boolean) {
    console.info(`${TAG} onScreen ${isFull}`)
    if (!isFull) {
      this.goBack()
    }
  }
  sendPlayVideo() {
    console.info(`${TAG} sendPlayVideo`)
    emitter.emit({
      eventId: CommonData.EVENT_PLAY_VIDEO
    }, {
      data: {
        cur_time: this.curTime,
        video_state: this.videoState
      }
    })
  }
  goBack() {
    this.sendPlayVideo()
    router.back()
  }
  aboutToDisappear() {
  }
  build() {
    Stack({
      alignContent: Alignment.TopStart
    }) {
      VideoView({
        _TAG: this.mTag,
        videoUri: $uri,
        previewUri: $previewImage,
        videoRate: $rate,
        videoRateIndex: $rateIndex,
        onScreen: this.onScreen.bind(this),
        videoState: $videoState,
        isFullScreen: true,
        isEvent: false,
        mWidth: '100%',
        mHeight: '100%'
      })
      if (this.isShowOperation) {
        Row({ space: 10 }) {
          Image($r('app.media.icon_back'))
            .width(24)
            .height(24)
            .objectFit(ImageFit.Cover)
            .onClick(() => {
              this.goBack()
            })
          Text(this.name)
            .fontSize(20)
            .fontColor(Color.White)
        }
        .padding(20)
      }
    }
    .width('100%')
    .height('100%')
  }
}

界面代码非常简单,所有的功能在集成在VideoView组件中,这与视频播放页面相比,增加了电影播放倍数的选择,选择器使用Select下拉选择菜单实现,下面我们来详细的介绍下这个组件。

Select

提供了下拉选择菜单,让用户在多个选项之间选择。

Select(options: Array<SelectOption>)
SelectOption对象说明
参数名 参数类型 必填 参数描述
value ResourceStr 下拉选项内容。
icon ResourceStr 下拉选项图片。
属性
名称 参数类型 描述
selected number 设置下拉菜单初始选项的索引,第一项的索引为0。<br>当不设置selected属性时,默认选择值为-1,菜单项不选中。
value string 设置下拉按钮本身的文本内容。
font Font 设置下拉按钮本身的文本样式。
fontColor ResourceColor 设置下拉按钮本身的文本颜色。
selectedOptionBgColor ResourceColor 设置下拉菜单选中项的背景色。
selectedOptionFont Font 设置下拉菜单选中项的文本样式。
selectedOptionFontColor ResourceColor 设置下拉菜单选中项的文本颜色。
optionBgColor ResourceColor 设置下拉菜单项的背景色。
optionFont Font 设置下拉菜单项的文本样式。
optionFontColor ResourceColor 设置下拉菜单项的文本颜色。
事件
名称 功能描述
onSelect(callback: (index: number, value?: string) => void) 下拉菜单选中某一项的回调。<br/>index:选中项的索引。<br/>value:选中项的值。

本案例中的Select组件是在VideoView.ets视频播放子组件中实现的,核心代码如下:

VideoView.ets

if (this.isFullScreen) {
            Select(this.selectSpeedOption)
              .selected(this.videoRateIndex)
              .value(this.videoRate.val)
              .font({ size: 10 })
              .fontColor(Color.White)
              .selectedOptionFont({ size: 10 })
              .selectedOptionFontColor('#F54F02')
              .optionFontColor('#5E5E5E')
              .optionFont({ size: 10 })
              .onSelect((index: number) => {
                console.info('Select:' + index)
                this.videoRate = PLAYBACK_SPEED[index]
                this.videoRateIndex = index
                console.info(`${TAG} videoRateIndex = ${this.videoRateIndex}`)
              })
              .border({
                width: 0,
                color: Color.White
              })
          }

2、横竖屏切换
2.1、如何实现横竖屏切换

首先我们知道由于的界面需要集成到一个窗口上,这个窗口就是Window,在应用启动时会触发UIAbility的生命周期方法onWindowStageCreate(),此接口的回调中带有一个参数就是WindowStage窗口管理器,窗口管理器可以通过getMainWindow()接口获取到主窗口,返回当前窗口的实例Window,得到窗口实例后就可以通过setPreferredOrientation()设置窗口的显示方向。

setPreferredOrientation
setPreferredOrientation(orientation: Orientation, callback: AsyncCallback&lt;void&gt;): void

设置窗口的显示方向属性,使用callback异步回调。

参数:

参数名 类型 必填 说明
Orientation Orientation 窗口显示方向的属性。
callback AsyncCallback<void> 回调函数。
Orientation

窗口显示方向类型枚举。

名称 说明
UNSPECIFIED 表示未定义方向模式,由系统判定。
PORTRAIT 1 表示竖屏显示模式。
LANDSCAPE 2 表示横屏显示模式。
PORTRAIT_INVERTED 3 表示反向竖屏显示模式。
LANDSCAPE_INVERTED 4 表示反向横屏显示模式。
AUTO_ROTATION 5 表示传感器自动旋转模式。
AUTO_ROTATION_PORTRAIT 6 表示传感器自动竖向旋转模式。
AUTO_ROTATION_LANDSCAPE 7 表示传感器自动横向旋转模式。
AUTO_ROTATION_RESTRICTED 8 表示受开关控制的自动旋转模式。
AUTO_ROTATION_PORTRAIT_RESTRICTED 9 表示受开关控制的自动竖向旋转模式。
AUTO_ROTATION_LANDSCAPE_RESTRICTED 10 表述受开关控制的自动横向旋转模式。
LOCKED 11 表示锁定模式。

具体如何实现呢? 我们知道由于启动时会加重UIAbility,在项目中EntryAbility继承UIAbility,所以可以在EntryAbility.ts中获取Window实例设置其窗口显示方向来实现横竖屏切换,代码如下:


import UIAbility from '@ohos.app.ability.UIAbility';
import hilog from '@ohos.hilog';
import window from '@ohos.window';
import emitter from '@ohos.events.emitter';
import { CommonData } from '../model/CommonData'
export default class EntryAbility extends UIAbility {
    private mWindow : window.Window
    onCreate(want, launchParam) {
        
    }
    onDestroy() {
      
        // 设置竖屏
        this.mWindow.setPreferredOrientation(window.Orientation.PORTRAIT)
        this.unregisterEmitter()
    }
    onWindowStageCreate(windowStage: window.WindowStage) {
        // Main window is created, set main page for this ability
        this.mWindow = windowStage.getMainWindowSync()
        this.registerEmitter()
        windowStage.loadContent('pages/Splash', (err, data) => {
            if (err.code) {
                return;
            }
        });
    }

    registerEmitter() {
        emitter.on({
            eventId : CommonData.EVENT_WINDOW_PORTRAIT_ID
        }, () => {
            if (!this.mWindow) {
                return
            }
          this.mWindow.setPreferredOrientation(window.Orientation.PORTRAIT)
        })
        emitter.on({
            eventId : CommonData.EVENT_WINDOW_LANDSCAPE_ID
        }, () => {
            if (!this.mWindow) {
                return
            }
            this.mWindow.setPreferredOrientation(window.Orientation.LANDSCAPE)
        })
    }
    unregisterEmitter() {
        emitter.off(CommonData.EVENT_WINDOW_PORTRAIT_ID)
        emitter.off(CommonData.EVENT_WINDOW_LANDSCAPE_ID)
    }
}

由于视频播放页面和全屏播放页面与EntryAbility无直接联系,如果在操作页面时修改窗口方向呢? 我相信你也注意到了上面的代码中使用到了@ohos.events.emitter,emitter提供了在同一进程不同线程之间或者同一进程同一线程内,发送和处理事件的能力,可以通过订阅事件、取消订阅、发送事件等接口实现消息线程通信。所以我们在EntryAbility的onWindowStageCreate()接口回调时订阅了横竖屏切换事件,当然在应用退出时,也就是在onDestroy()接口被回调时,应该注取消订阅,防止内存泄漏,消息错乱。

2.2、发送横竖屏切换事件
  • 播放页面切换到全屏播放时界面切换成横屏,需要在FullScreen.ets界面被启动回调aboutToAppear()接口时发送横屏事件,通知Window修改方向。FullScreen.ets中的核对代码:

 aboutToAppear() {
    // 横屏显示
    emitter.emit({
      eventId: CommonData.EVENT_WINDOW_LANDSCAPE_ID
    })
  }

  • 全屏播放返回到视频播放页时需要将横屏切换到竖屏显示,所以当Playback.ets页面的onPageShow()接口被触发时,就发送竖屏事件,通知Window修改方向。Playback.ets中的核心代码:

onPageShow() {
    // 竖屏显示
    emitter.emit({
      eventId: CommonData.EVENT_WINDOW_PORTRAIT_ID
    })
  }

这样就完成了视频播放页面为竖屏,全屏播放为横屏的功能。

3、播放时间同步

播放时间同步主要在视频播放页面与全屏播放页面相互切换时使用,在两个页面切换时,除了时间同步外,播放状态也需要同步。时间同步是指:视频播放页面在播放视频时,假设播放到5s这个时间帧节点时,切换到全屏播放页面,全屏播放进入播放状态,且从5s这个时间帧节点开始播放。

如上所述,两个页面之间必须同步播放时间戳,页面切换通过路由器@ohos. router 实现,在router.pushUrl()函数中可以添加参数,我们将时间戳通过自定义参数传递到目标界面,页面返回到上一级页面时,一般使用router.back(),此时通过发送事件同步消息实现视频播放时间同步。具体实现请参看FullScreen.ets、Playback.ets、VideoView.ets三个类。

这个就是全屏播放页面的实现,到目前已经将视频播放器的所有页面实现讲述完毕,如果你有遇到什么问题欢迎留言讨论。

感谢

如果您能看到最后,还希望您能动动手指点个赞,一个人能走多远关键在于与谁同行,我用跨越山海的一路相伴,希望得到您的点赞。

本文作者:NL_AIDC_XJS

想了解更多关于开源的内容,请访问:​

​51CTO 开源基础软件社区​

​https://ost.51cto.com/#bkwz​