鸿蒙5.0 APP开发案例分析:视频播放开发实践

往期推文全新看点(文中附带全新鸿蒙5.0全栈学习笔录)

✏️ 鸿蒙应用开发与鸿蒙系统开发哪个更有前景?

✏️ 嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~

✏️ 对于大前端开发来说,转鸿蒙开发究竟是福还是祸?

✏️ 鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?

✏️ 记录一场鸿蒙开发岗位面试经历~

✏️ 持续更新中……


概述

本文适用于视频播放类应用的开发,针对市场上主流视频播放类应用的常见场景,介绍了如何基于HarmonyOS能力快速实现视频播放应用。

从用户交互和音频流状态变更两个维度,指导开发者基于HarmonyOS提供的媒体和ArkUI等能力,实现视频前后台播放控制、播放形态切换、音频焦点切换、播放设备切换等场景,可以为视频播放应用提供灵活的交互体验和良好的观看效果。

说明
在阅读本文之前,建议开发者先熟悉视频播放器《AVPlayer实现》,如果应用希望实现短视频播放功能,还需要参考《在线短视频流畅切换》。

场景分析

与用户交互

播放进度控制

进度条控制

进度条作为视频应用的一个基础能力,可以通过点击或拖动进度条来调节视频播放进度。采用 Slider组件 实现进度条功能,根据Slider组件属性设置进度条样式,并在其onChange()事件中触发视频播放器AVPlayer的seek()方法,实现视频进度的控制。

Slider({
  value: this.isSliderGesture ? this.panEndTime : this.avPlayerController.currentTime,
  step: 0.1,
  min: 0,
  max: this.avPlayerController.durationTime,
  style: this.sliderStyle
})
  // ...
  .trackColor($r('app.color.white_opacity_1_color')) // 滑轨背景颜色
  .showSteps(false) // 是否显示步长刻度
  .blockSize({ width: this.blockSize, height: this.blockSize }) // 滑块大小
  .blockColor($r('sys.color.background_primary')) // 滑块颜色
  .trackThickness(this.trackThicknessSize) // 滑轨粗细
  .trackBorderRadius(2) // 底板圆角半径
  .selectedBorderRadius(2) // 已滑动部分圆角半径
  // ...
  .onChange((value: number, mode: SliderChangeMode) => {
    this.sliderOnchange(value, mode); // 进度条变化接口
  })

sliderOnchange(seconds: number, mode: SliderChangeMode) {
  let seekTime: number = seconds * this.avPlayerController.duration / this.avPlayerController.durationTime;
  this.currentStringTime = secondToTime(Math.floor(seekTime / 1000));
  this.avPlayerController.setCurrentStringTime(this.currentStringTime);
  switch (mode) {
    case SliderChangeMode.Begin:
      break;
    case SliderChangeMode.Click:
      break;
    case SliderChangeMode.Moving:
      // ...
      break;
    case SliderChangeMode.End:
      this.avPlayerController.seek(seekTime); // 调用AVPlayer的seek方法控制播放进度
      // ...
      break;
    default:
      break;
  }
}

手势调节播放进度

通常视频播放应用还可以支持手势滑动来调节播放的进度,可以给组件绑定手势识别,来实现在视频界面左右滑动调节视频播放进度的能力,手势类型选择PanGesture (平移手势)。

  1. onActionStart()阶段使用本地变量记录当前视频播放的位置,并更新进度条状态变量,重新渲染UI界面;
  2. onActionUpdate()阶段根据滑动的距离,计算滑动后视频应跳转的播放位置,并渲染UI界面进度条动效;
  3. onActionEnd()阶段将最终计算出的播放位置传给AVPlayer控制器,调用AVPlayer的seek()方法让视频跳转到指定播放位置,并更新进度条状态变量,重新渲染UI界面。
build() {
  Stack({ alignContent: Alignment.BottomEnd }) {
    // ...
    XComponent({
      id: 'XComponent',
      type: XComponentType.SURFACE,
      controller: this.xComponentController
    })
    // ...
  }
   // ...
  .gesture(
    PanGesture({ direction: PanDirection.Horizontal })
      .onActionStart((event: GestureEvent) => {
        this.isSliderGesture = true;
        this.panStartX = event.offsetX;
        this.panStartTime = this.avPlayerController.currentTime;
        this.sliderOnchange(this.panStartTime, SliderChangeMode.Begin);
      })
      .onActionUpdate((event: GestureEvent) => {
        this.isSliderGesture = true;
        let panTime =
          this.panStartTime +
            (this.panStartX + event.offsetX) / this.slideWidth * this.avPlayerController.durationTime;
        this.panEndTime = Math.min(Math.max(0, panTime), this.avPlayerController.durationTime);
        this.sliderOnchange(this.panEndTime, SliderChangeMode.Moving);
      })
      .onActionEnd(() => {
        this.sliderOnchange(this.panEndTime, SliderChangeMode.End);
        this.isSliderGesture = false;
      })
  )
}

显示进度条弹窗

在正常浏览视频的过程中,应用会记录用户的浏览历史,当再次切换到原视频时,根据历史数据在进度条上以弹窗的形式显示相关信息。并且让弹窗跟随滑块位置移动,弹窗保留1秒后消失。同时历史数据的保留跟随视频组件的生命周期存亡。

由于Slider自带的showTips无法对弹窗样式进行自定义,只支持圆形气泡,且无法自定义控制弹窗的显示时长和出现时机。所以我们通过创建一个和Slider滑块大小一致的透明Stack,并计算滑块在屏幕中的位置,将计算的位置数据同步设置给Stack,并给Stack绑定Popup弹窗跟随Slider滑块运动。

关键代码:

通过给Slider组件绑定区域变化事件onAreaChange(),计算滑动位置信息。

// Slider进度条
Slider({
  value: this.isSliderGesture ? this.panEndTime : this.avPlayerController.currentTime,
  step: 0.1,
  min: 0,
  max: this.avPlayerController.durationTime,
  style: this.sliderStyle
})
  // ...
  .blockSize({ width: this.blockSize, height: this.blockSize })
  // 计算滑块位置
  .onAreaChange(() => {
    let videoSlider: componentUtils.ComponentInfo = componentUtils.getRectangleById('video_slider')
    this.slideWidth = px2vp(videoSlider.size.width);
    // 计算offsetY:Slider滑块位置的纵坐标
    this.offsetY = px2vp(videoSlider.localOffset.y);
    this.beginX = px2vp(videoSlider.localOffset.x);
  })
  // 进度条变化时最终会调用avPlayer.seek()接口
  .onChange((value: number, mode: SliderChangeMode) => {
    this.sliderOnchange(value, mode);// 进度条变化时会调用avPlayer.seek()接口
  })

Stack透明块大小、位置设置以及给Stack绑定Popup弹窗。

// 设置和Slider大小一样的透明stack块
Stack() {
}
.backgroundColor($r('sys.color.background_primary'))
.width(this.blockSize)
.height(this.blockSize)
.borderRadius($r('app.float.font_size_20'))
.visibility(this.isShowTips ? Visibility.Visible : Visibility.None)
.position({ x: this.tipsOffset, y: this.offsetY })
// 将Popup绑定到Stack块上,跟随Slider滑块运动
.bindPopup(this.isShowTips, {
  builder: this.popupBuilder,
  placement: Placement.Top,
  mask: false,
  arrowOffset: 0,
  popupColor: $r('sys.color.background_primary'),
  backgroundBlurStyle: BlurStyle.BACKGROUND_ULTRA_THICK,
  enableArrow: true,
  arrowPointPosition: ArrowPointPosition.CENTER,
  radius: this.blockSize,
})

播放形态切换

横竖屏切换

横竖屏切换是视频应用常见的功能,用户会根据自己喜好选择在横屏或竖屏模式下观看。

设置窗口旋转策略有两种方式:

  1. 通过module.json5文件中“orientation”字段进行设置。
  2. 在代码中通过调用窗口window的 setPreferredOrientation() 方法进行设置。

这两种方式触发设置旋转的时机不同,module.json5文件中的字段在窗口启动时就会生效,对于应用启动时就需要设置横屏或者竖屏的应用,可以进行配置。而setPreferredOrientation()是在调用该方法时进行窗口方向的设置,用于在应用启动之后,还需要改变显示方向的场景。

本示例主要介绍通过使用窗口的setPreferredOrientation()方法来实现应用的横竖屏切换能力,下面给出具体实现方案 。

  1. 设置窗口旋转

当进入首页视频列表时,仅支持竖屏,要切换视频播放页面为横屏时,采用window窗口能力,通过setPreferredOrientation()将窗口显示的方向修改为横屏、竖屏的状态。在使用时,根据应用自身的旋转策略选择相应的参数。窗口显示方向类型枚举可以参考:window.Orientation。

// 根据应用自身的旋转策略选择相应的参数
setMainWindowOrientation(orientation: window.Orientation, callback?: Function): void {
  // ...
  this.mainWindowClass.setPreferredOrientation(orientation).then(() => {
    callback?.();
  }).catch((err: BusinessError) => {
    hilog.error(0x0001, TAG, `Failed to set the ${orientation} of main window. Code:${err.code}, message:${err.message}`);
  });
}
  1. 监听窗口变化

    当用户手动设置窗口方向时,窗口的显示会发生变化,对应窗口的size也会发生改变,此时可以通过拿到窗口的宽高,并对宽高进行对比,判断当前显示是竖屏还是横屏状态,并利用该数据对布局进行适配。监听窗口尺寸的变化可以通过 window.on(‘windowSizeChange’) 进行实现。

// 在aboutToAppear中开启窗口尺寸监听
async aboutToAppear(): Promise<void> {
  let context = getContext(this);
  let windowClass = await window.getLastWindow(context);
  // ...
  // 注册窗口尺寸监听
  this.windowUtil.registerOnWindowSizeChange((size) => {
    if (size.width > size.height) {
      // 横屏逻辑
      this.isFullLandscapeScreen = true;
    } else {
      // 竖屏逻辑
      this.isFullLandscapeScreen = false;
    }
  });
}

registerOnWindowSizeChange(callback?: (size: window.Size) => void) {
  // ...
  this.mainWindowClass.on('windowSizeChange', (size) => {
    // 由于在手机中应用窗口真实宽度为屏幕宽度,故不需再改变宽度
    AppStorage.setOrCreate('deviceHeight', size.height);
    callback?.(size);
  });
}

悬浮窗播放

视频应用添加悬浮窗能力,可以让用户在观看视频时,临时处理另一个任务或短时间多任务并行使用,如边看视频边浏览网页等行为。

  1. 声明支持悬浮窗
    首先需要通过对module.json5配置文件中abilities标签下的 supportWindowMode 属性增加“floating”字段或使用缺省值以声明应用支持悬浮窗。

因为本视频应用需要支持横向和竖向悬浮窗两种能力,所以还需对abilities标签下的preferMultiWindowOrientation属性设置为landscape_auto,来标识当前UIAbility组件多窗布局方向。

"abilities": [
  {
    // ...
    "preferMultiWindowOrientation": "landscape_auto"
  }
]
  1. 适配悬浮窗布局

由于应用从全屏进入悬浮窗后,应用的窗口尺寸会发生变化,所以应用需要根据不同的窗口尺寸调整自身布局。可以通过窗口的 on(‘windowSizeChange’) 方法实现对窗口尺寸大小变化的监听。再根据窗口的尺寸变化,更新调整自身应用布局以实现适配。

  • 在onWindowStageCreate()方法中,获取Window对象。
onWindowStageCreate(windowStage: window.WindowStage): void {
  // ...
  WindowUtil.getInstance().setWindowStage(windowStage);
  // ...
}
  • 通过getWindowProperties()方法返回值中的windowRect获取窗口尺寸,写入AppStorage中用于UI侧窗口尺寸的首次初始化赋值。
public setWindowStage(windowStage: window.WindowStage): void {
  this.windowStage = windowStage;
  this.windowStage.getMainWindow((err, windowClass: window.Window) => {
    // ...
    this.mainWindowClass = windowClass;
    const properties = windowClass.getWindowProperties(); // 获取窗口信息
    // ...
    AppStorage.setOrCreate('deviceWidth', properties.windowRect.width); // 设置窗口宽度
    AppStorage.setOrCreate('deviceHeight', properties.windowRect.height); // 设置窗口高度
    // ...
  });
}
  • 使用on(‘windowSizeChange’)注册窗口尺寸变化时的监听,并写入AppStorage中供UI侧布局使用。
registerOnWindowSizeChange(callback?: (size: window.Size) => void) {
  // ...
  this.mainWindowClass.on('windowSizeChange', (size) => {
    // 由于在手机中应用窗口真实宽度为屏幕宽度,故不需再改变宽度
    AppStorage.setOrCreate('deviceHeight', size.height);
    callback?.(size);
  });
}
  • UI侧通过@StorageProp绑定窗口尺寸后,AppStorage中属性key值对应的数据一旦改变,UI侧会同步修改。
@Entry
@Component
struct IndexPage {
  @State isFloatWindow: boolean = false;
  @StorageProp('deviceHeight') @Watch('onWindowSizeChange') deviceHeight: number = AppStorage.get<number>('deviceHeight') || 0;
  // ...
}
  • @Watch用于对状态变量的监听,所以窗口尺寸发生变化时,会引起组件的重新渲染,开发者可以根据最新的窗口尺寸动态调整应用布局。
// 因为悬浮窗宽高为系统默认,所以此处只根据窗口高度来处理isFloatWindow(是否显示悬浮窗)的值,用来控制界面组件的显隐
onWindowSizeChange() {
  let deviceWidth = AppStorage.get<number>('deviceWidth') || 0;
  // 判断是否是横屏或竖屏悬浮窗
  if (this.isFullLandscapeScreen && Math.round((this.deviceHeight / deviceWidth) * 1000) / 1000 === 0.563) {
    this.isFloatWindow = true;
  } else if (this.isFullScreen &&
    (Math.round((deviceWidth / this.deviceHeight) * 1000) / 1000 === 0.563 || Math.round((deviceWidth / this.deviceHeight) * 10) / 10 === 0.6)) {
    this.isFloatWindow = true;
  } else {
    this.isFloatWindow = false;
  }
}
build() {
  // ...
  VideoPlayer({
    // ...
    isFloatWindow: this.isFloatWindow,
    // ...
  })
  // ...
}

播控中心控制视频状态

用户除了在视频界面控制视频状态外,还可以通过给应用接入AVSession播控中心,使用户在播控中心也能看到当前播放视频的信息,并通过播控中心直接对视频进行快进、快退、拖动进度、播放暂停、切换、调节音量等操作。同时应用内的视频状态与信息也会与播控中心相互同步,避免了只有在视频界面才能控制视频状态的单一场景 。

  1. 创建并激活媒体会话
    在AvSessionController初始化时,通过createAVSession()创建AVSession实例并激活媒体会话,视频应用选择会话类型为video。开发者可根据应用的类型选择对应的会话。
import { avSession } from '@kit.AVSessionKit';

export class AvSessionController {
  private avSession: avSession.AVSession | undefined = undefined;

  public initAvSession() {
    this.context = AppStorage.get('context');
    if (!this.context) {
      hilog.info(0x0001, TAG, "session create failed : context is undefined");
      return;
    }
    // 创建AVSession,会话类型设置为'video'
    avSession.createAVSession(this.context, "SHORT_AUDIO_SESSION", 'video').then(async (avSession) => {
      this.avSession = avSession;
      hilog.info(0x0001, TAG, `session create successed : sessionId : ${this.avSession.sessionId}`);
      // 设置用于被播控中心拉起的UIAbility
      this.setLaunchAbility();
      // 激活媒体会话
      this.avSession.activate();
    });
  }
}
  1. 设置媒体会话元数据
    应用可以通过setAVMetadata()把会话的一些元数据信息设置给系统,从而在播控中心界面进行展示。如媒体ID(assetId)、标题(title)、播控中心显示的图片(mediaImage)、媒体时长(duration)等。
public async setAVMetadata(curSource: VideoData, duration: number) {
  // ...
  const imagePixMap = await ImageUtil.getPixmapFromMedia(curSource.head);
  let metadata: avSession.AVMetadata = {
    assetId: `${curSource.index}`, // 媒体ID
    title: this.context?.resourceManager.getStringSync(curSource.name), // 标题
    mediaImage: imagePixMap, // 图片的像素数据或者图片路径地址
    duration: duration // 媒体时长,单位毫秒(ms)
  };
  if (this.avSession) {
    this.avSession.setAVMetadata(metadata).then(() => { // 调用设置会话元数据接口
      this.avSessionMetadata = metadata;
      hilog.info(0x0001, TAG, "SetAVMetadata successfully");
    }).catch((err: BusinessError) => {
      hilog.error(0x0001, TAG, `SetAVMetadata BusinessError: code: ${err.code}, message: ${err.message}`);
    });
  }
}
  1. 设置用于被播控中心拉起的UIAbility
    当用户操作媒体会话控制方的界面时,例如点击播控中心的卡片,可以拉起此处配置的UIAbility。将封装的WantAgent通过setLaunchAbility()配置给媒体会话。
private setLaunchAbility() {
  // ...
  const wantAgentInfo: wantAgent.WantAgentInfo = {
    wants: [
      {
        bundleName: this.context.abilityInfo.bundleName, // 待启动Ability所在的应用Bundle名称
        abilityName: this.context.abilityInfo.name // 待启动Ability名称
      }
    ],
    operationType: wantAgent.OperationType.START_ABILITIES, // 动作类型,START_ABILITY 表示开启多个有页面的Ability
    requestCode: 0, // 使用者定义的一个私有值
    wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] // 动作执行属性,UPDATE_PRESENT_FLAG 表示使用新的WantAgent的额外数据替换已存在的WantAgent中的额外数据
  };
  wantAgent.getWantAgent(wantAgentInfo).then((agent) => {
    if (this.avSession) {
      this.avSession.setLaunchAbility(agent);
    }
  });
}
  1. 注册播控命令事件监听
    便于响应用户通过播控中心下发的播控命令,比如播放、暂停、停止、快进、快退等。同时只有设置了相关的事件回调,播控中心上对应的按钮才会亮起,否则为置灰状态。
public async setAvSessionListener() {
  if (!this.avSessionController) {
    return;
  }
  this.avSessionController.getAvSession()?.on('play', () => this.sessionPlayCallback()); // 设置播放命令监听事件
  this.avSessionController.getAvSession()?.on('pause', () => this.sessionPauseCallback()); // 设置暂停命令监听事件
  this.avSessionController.getAvSession()?.on('stop', () => this.sessionStopCallback()); // 设置停止命令监听事件
  this.avSessionController.getAvSession()?.on('fastForward', (time?: number) => this.sessionFastForwardCallback(time)); // 设置快进命令监听事件
  this.avSessionController.getAvSession()?.on('rewind', (time?: number) => this.sessionRewindCallback(time)); // 设置快退命令监听事件
  this.avSessionController.getAvSession()?.on('seek', (seekTime: number) => this.sessionSeekCallback(seekTime)); // 设置跳转节点监听事件
}
  1. 应用状态上报播控中心

当视频状态发生改变时,需要通过setAVPlaybackState()向播控中心上报视频状态,来达到播控中心与应用的状态同步,包括播放状态(state)、播放位置(position)、当前媒体播放时长(duration)等。

private updateIsPlay(isPlay: boolean) {
  if (this.curIndex !== this.curSource.index) {
    return;
  }
  this.isPlaying = isPlay;
  this.avSessionController.setAvSessionPlayState({
    state: isPlay ? avSession.PlaybackState.PLAYBACK_STATE_PLAY : avSession.PlaybackState.PLAYBACK_STATE_PAUSE, //播放状态
    position: { // 播放位置
      elapsedTime: this.currentTime * 1000, // 已用时间,单位毫秒(ms)
      updateTime: new Date().getTime() // 更新时间,单位毫秒(ms)
    },
    duration: this.duration // 当前媒体资源的时长
  });
}

public setAvSessionPlayState(playbackState: avSession.AVPlaybackState) {
  if (this.avSession) {
    this.avSession.setAVPlaybackState(playbackState, (err: BusinessError) => {
      if (err) {
        hilog.error(0x0001, TAG, `SetAVPlaybackState BusinessError: code: ${err.code}, message: ${err.message}`);
      } else {
        hilog.info(0x0001, TAG, "SetAVPlaybackState successfully");
      }
    });
  }
}

视频后台播放

用户在观看视频时,会遇到退出视频应用至后台的情况。当应用退到后台时,应用进程很快就会被挂机,所以为满足视频可以在后台持续播放的能力,需要对视频应用申请长时任务,来防止应用进程挂起。同时配合AVSession播控能力,用户可以做到在后台直接与视频应用进行交互,达到更加灵活的体验效果。

说明
只有使用了媒体会话服务(AVSession)的音视频应用,才能申请长时任务实现后台播放。

接入流程和关键代码

  1. 申请后台运行权限和声明后台模式
    在module.json5配置文件中申请ohos.permission.KEEP_BACKGROUND_RUNNING权限和配置后台模式audioPlayback。
"requestPermissions": [
  {
    "name": "ohos.permission.KEEP_BACKGROUND_RUNNING",
    "reason": "$string:reason_background",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "always"
    }
  }
],

"abilities": [
  {
    "backgroundModes": [
      "audioPlayback"
    ],
  }
],
  1. 创建后台管理类
    通过获取到的UIAbility上下文和wantAgent模块下getWantAgent()方法获取WantAgent对象,使用BackgroundTasksKit的startBackgroundRunning()、stopBackgroundRunning()方法分别申请和取消后台运行任务,长时任务类型选择AUDIO_PLAYBACK,表示视频后台播放。
import { common, wantAgent } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = '[BackgroundTaskManager]';

/**
 * Background task tool class.
 */
export class BackgroundTaskManager {
  public static startContinuousTask(context?: common.UIAbilityContext): void {
    if (!context) {
      return;
    }
    let wantAgentInfo: wantAgent.WantAgentInfo = {
      wants: [
        {
          bundleName: context.abilityInfo.bundleName,
          abilityName: context.abilityInfo.name
        }
      ],
      operationType: wantAgent.OperationType.START_ABILITY,
      requestCode: 0,
      wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };

    wantAgent.getWantAgent(wantAgentInfo).then((wantAgentObj) => {
      backgroundTaskManager.startBackgroundRunning(context,
        backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK, wantAgentObj).then(() => {
        hilog.info(0x0001, TAG, "startBackgroundRunning succeeded");
      }).catch((err: BusinessError) => {
        hilog.error(0x0001, TAG, `startBackgroundRunning failed Cause:  ${JSON.stringify(err)}`);
      });
    });
  }

  public static stopContinuousTask(context?: common.UIAbilityContext): void {
    if (!context) {
      return;
    }
    backgroundTaskManager.stopBackgroundRunning(context).then(() => {
      hilog.info(0x0001, TAG, "stopBackgroundRunning succeeded");
    }).catch((err: BusinessError) => {
      hilog.error(0x0001, TAG, `stopBackgroundRunning failed Cause:  ${JSON.stringify(err)}`);
    });
  }
}
  1. 申请和销毁后台长时任务
    在AVSession创建和释放时,分别申请和销毁后台长时任务。
public initAvSession() {
  this.context = AppStorage.get('context');
  // ...
  avSession.createAVSession(this.context, "SHORT_AUDIO_SESSION", 'video').then(async (avSession) => {
    this.avSession = avSession;
    hilog.info(0x0001, TAG, `session create successed : sessionId : ${this.avSession.sessionId}`);
    // 申请后台长时任务
    BackgroundTaskManager.startContinuousTask(this.context);
    this.setLaunchAbility();
    this.avSession.activate();
  });
}

async unregisterSessionListener() {
  // ...
  // 销毁后台长时任务
  BackgroundTaskManager.stopContinuousTask(this.context);
}

滑动调节音量及亮度

在全屏播放视频的场景下,滑动调节音量及亮度是一项非常实用的功能,它允许用户在不离开视频播放界面的情况下,快速调整音量和亮度,展示音量及亮度大小,以获得更好的观影体验。该功能分为两部分,左侧滑动调整音量,右侧滑动调整亮度。

左侧滑动调节音量

在屏幕的左侧区域,使用 AVVolumePanel组件 显示系统音量面板,绑定 PanGesture 滑动手势事件,设置滑动方向为竖直方向,当Pan手势在移动过程中,上滑增加或者下滑减少音量,实现控制系统音量的功能。

  1. 使用 AVVolumePanel组件 设置音量面板;
Column() {
  AVVolumePanel({
    volumeLevel: this.volume,
    volumeParameter: {
      position: {
        x: 150,
        y: 300
      }
    }
  })
}
.width('50%')
  1. 绑定PanGesture滑动手势事件。
.gesture(
  PanGesture({ direction: PanDirection.Vertical })
    .onActionStart(() => {
    })
    .onActionUpdate((event: GestureEvent) => {
      if (event.fingerList[0].globalX > (820 / 2)) {
        // 相对于应用窗口左上角的x轴坐标在屏幕右侧,调整亮度
        this.visible = true;
        let curBrightness = this.screenBrightness - vp2px(event.offsetY) / vp2px(this.screenHeight);
        curBrightness = curBrightness >= 1.0 ? 1.0 : curBrightness;
        curBrightness = curBrightness <= 0.0 ? 0.0 : curBrightness;
        this.screenBrightness = curBrightness;
        hilog.info(0x0000, 'AVPlayer', `this brightness is: ` + this.screenBrightness);

        try {
          this.mainWin.setWindowBrightness(this.screenBrightness, (err) => {
            if (err) {
              hilog.error(0x0000, 'AVPlayer', `Failed to set the brightness. Cause: ${JSON.stringify(err)}`);
              return;
            }
            hilog.info(0x0000, 'AVPlayer', `Succeeded in setting the brightness.`);
          });
        } catch (exception) {
          hilog.error(0x0000, 'AVPlayer', `Failed to set the brightness.`);
        }
      } else {
        // 相对于应用窗口左上角的x轴坐标在屏幕左侧,调整音量
        this.visible = false;
        let curVolume = this.volume - vp2px(event.offsetY) / this.screenHeight;
        curVolume = curVolume >= 15.0 ? 15.0 : curVolume;
        curVolume = curVolume <= 0.0 ? 0.0 : curVolume;
        this.volume = curVolume;
        hilog.info(0x0000, 'AVPlayer', `this volume is: ` + this.volume);
      }
    })
    .onActionEnd(() => {
      setTimeout(() => {
        this.visible = false;
      }, 3000)
    })
)

右侧滑动调节亮度

在屏幕的右侧区域,使用 Slider组件 设置一个亮度面板,绑定 PanGesture 滑动手势事件,设置滑动方向为竖直方向,当Pan手势在移动过程中调用 setWindowBrightness 方法,实现上滑增加或者下滑减少亮度的功能。

  1. 使用 Slider组件 设置亮度面板;
Column() {
  Stack() {
    Slider({
      value: this.screenBrightness,
      min: 0,
      max: 1,
      step: 0.1,
      style: SliderStyle.NONE,
      direction: Axis.Vertical,
      reverse: true
    })
      .visibility(this.visible ? Visibility.Visible : Visibility.Hidden)
      .height(160)
      .selectedColor(Color.White)
      .trackColor(Color.Black)
      .trackThickness(40)

    Image($r('app.media.sun_max_fill'))
      .visibility(this.visible ? Visibility.Visible : Visibility.Hidden)
      .margin({ top: 120 })
      .width(20)
      .height(20)
  }
  .margin({
    top: 0,
    right: 0
  })
}
.alignItems(HorizontalAlign.End)
.justifyContent(FlexAlign.Center)
.padding({
  right: 30,
  bottom: 20
})
.height('100%')
.width('50%')
  1. 绑定PanGesture滑动手势事件。
.gesture(
  PanGesture({ direction: PanDirection.Vertical })
    .onActionStart(() => {
    })
    .onActionUpdate((event: GestureEvent) => {
      if (event.fingerList[0].globalX > (820 / 2)) {
        // 相对于应用窗口左上角的x轴坐标在屏幕右侧,调整亮度
        this.visible = true;
        let curBrightness = this.screenBrightness - vp2px(event.offsetY) / vp2px(this.screenHeight);
        curBrightness = curBrightness >= 1.0 ? 1.0 : curBrightness;
        curBrightness = curBrightness <= 0.0 ? 0.0 : curBrightness;
        this.screenBrightness = curBrightness;
        hilog.info(0x0000, 'AVPlayer', `this brightness is: ` + this.screenBrightness);

        try {
          this.mainWin.setWindowBrightness(this.screenBrightness, (err) => {
            if (err) {
              hilog.error(0x0000, 'AVPlayer', `Failed to set the brightness. Cause: ${JSON.stringify(err)}`);
              return;
            }
            hilog.info(0x0000, 'AVPlayer', `Succeeded in setting the brightness.`);
          });
        } catch (exception) {
          hilog.error(0x0000, 'AVPlayer', `Failed to set the brightness.`);
        }
      } else {
        // 相对于应用窗口左上角的x轴坐标在屏幕左侧,调整音量
        this.visible = false;
        let curVolume = this.volume - vp2px(event.offsetY) / this.screenHeight;
        curVolume = curVolume >= 15.0 ? 15.0 : curVolume;
        curVolume = curVolume <= 0.0 ? 0.0 : curVolume;
        this.volume = curVolume;
        hilog.info(0x0000, 'AVPlayer', `this volume is: ` + this.volume);
      }
    })
    .onActionEnd(() => {
      setTimeout(() => {
        this.visible = false;
      }, 3000)
    })
)

音频流状态交互

多音频并发打断

当多个音频流并发播放时,如果系统不加管控,会造成多音频流混音播放,容易让用户感到嘈杂,造成不好的用户体验。为了解决这个问题,系统预设了音频打断策略,对多音频播放的并发进行管控,只有持有音频焦点的音频流才可以正常播放,避免出现多个音频流无序并发播放的现象。当音频打断事件发生时,系统会根据预设策略,对音频流做出相应的操作,并向音频流状态变化的应用发送音频打断事件。

通过给AVPlayer设置 on(‘audioInterrupt’) 函数进行监听,当收到音频打断事件(InterruptEvent)时,应用根据其内容做出相应的处理策略。

说明
1、如果使用了AvPlayer播放视频,audioRendererInfo会被默认设置成Movie,应用无需处理。

2、使用on(‘audioInterrupt’)监听音频打断时,要注意使用的视频资源必须有音频流,否则无法触发音频打断事件。

音频打断状态图

以下分别对四种打断场景进行说明:

先暂停后恢复场景

场景描述:视频正常播放中,当后播应用音频类型为闹钟/电话/铃声时,视频暂停播放,待后播应用音频结束后,视频恢复播放。

  1. 当后播应用响起时,视频应用会监听到音频打断类型为InterruptForceType.INTERRUPT_FORCE(强制打断),中断提示为InterruptHint.INTERRUPT_HINT_PAUSE(音频暂停)事件,此时系统内部会自动暂停视频播放,但AVPlayer播放器的状态不会自动变为暂停,应用需要主动调用AVPlayer的暂停接口来保证状态一致。
  2. 当后播应用音频结束后,视频应用会监听到打断类型为InterruptForceType.INTERRUPT_SHARE(共享打断),中断提示为InterruptHint.INTERRUPT_HINT_RESUME(音频恢复)事件,此时系统不会自动恢复视频播放,应用需要在相应的事件中主动调用AVPlayer的播放接口完成恢复。

降低音量后恢复场景

场景描述:视频正常播放中,当后播应用音频类型为导航/TextReader控件朗读语音/语音助手类短语音时,视频会降低音量持续播放,待后播应用音频结束后,视频音量恢复。

  1. 当后播应用响起时,视频应用会监听到音频打断类型为InterruptForceType.INTERRUPT_FORCE(强制打断),中断提示为InterruptHint.INTERRUPT_HINT_DUCK(降低音量)事件,此时系统会自动降低视频音量并持续播放,应用无需处理,如果应用想实现自己的规则,可在相应的事件类型下进行处理。
  2. 当后播应用音频结束后,视频应用会监听到打断类型为InterruptForceType.INTERRUPT_FORCE(强制打断),中断提示为InterruptHint.INTERRUPT_HINT_UNDUCK(恢复音量)事件。此处系统会自动恢复视频音量,应用无需处理,如果应用想实现自己的规则,也可在相应的事件类型下进行处理。

停止后不恢复场景

场景描述:视频正常播放中,当后播应用音频类型为音乐/视频/VOIP时,当前视频停止播放,且后播应用音频结束后,视频不会恢复。

后播应用响起时,视频应用会监听到音频打断类型为InterruptForceType.INTERRUPT_FORCE(强制打断),中断提示为InterruptHint.INTERRUPT_HINT_STOP(音频结束)事件,此时系统内部会自动停止视频播放,但AVPlayer播放器状态不会自动改变,应用需要根据自身需求做出相应处理,如主动调用AVPlayer的暂停接口保证视频状态。

并发混音场景

场景描述:视频正常播放中,当后播应用音频类型为游戏/系统音效(锁屏/按键)时,视频会与后播应用并发播放。此场景为系统默认行为,应用无需适配。

关键代码:

private setInterruptCallback() {
  if (!this.avPlayer) {
    return;
  }
  this.avPlayer.on('audioInterrupt', async (interruptEvent: audio.InterruptEvent) => {
    if (interruptEvent.forceType === audio.InterruptForceType.INTERRUPT_FORCE) {
      // 强制打断类型(INTERRUPT_FORCE):音频相关处理已由系统执行,应用需更新自身状态,做相应调整
      switch (interruptEvent.hintType) {
        case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
          // 此分支表示系统已将音频流暂停(临时失去焦点),为保持状态一致,应用需切换至音频暂停状态
          // 临时失去焦点:待其他音频流释放音频焦点后,本音频流会收到resume对应的音频打断事件,到时可自行继续播放
          this.updateIsPlay(false);
          this.pauseVideo();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_STOP:
          // 此分支表示系统已将音频流停止(永久失去焦点),为保持状态一致,应用需切换至音频暂停状态
          // 永久失去焦点:后续不会再收到任何音频打断事件,若想恢复播放,需要用户主动触发。
          this.updateIsPlay(false);
          this.pauseVideo();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_DUCK:
          // 此分支表示系统自动将您的视频音量降低,应用无需处理
          // 若应用不接受降低音量播放,可在此处选择其他处理方式,如主动暂停等
          break;
        case audio.InterruptHint.INTERRUPT_HINT_UNDUCK:
          // 此分支表示系统已将音频音量恢复正常,应用无需处理
          break;
        default:
          break;
      }
    } else if (interruptEvent.forceType === audio.InterruptForceType.INTERRUPT_SHARE) {
      // 共享打断类型(INTERRUPT_SHARE):应用可自主选择执行相关操作或忽略音频打断事件
      switch (interruptEvent.hintType) {
        case audio.InterruptHint.INTERRUPT_HINT_RESUME:
          // 此分支表示临时失去焦点后被暂停的音频流此时可以继续播放,建议应用继续播放,切换至音频播放状态
          // 若应用此时不想继续播放,可以忽略此音频打断事件,不进行处理即可
          // 继续播放,此处主动执行start(),以标识符变量started记录start()的执行结果
          this.playVideo();
          break;
        default:
          break;
      }
    }
  })
}

播放设备切换

当应用使用的播放设备状态发生改变时,需要根据不同的场景及时做出相应的处理,来保证视频状态与交互体验的一致,如:视频播放状态变化、目标设备切换、视频界面的更新等。

实现方式通过对AVPlayer注册 on(‘audioOutputDeviceChangeWithInfo’) 监听事件,来感知播放设备的切换,并根据流设备的变更原因,实现对应的切换规则。

说明
1、如果没有对AVPlayer注册 on(‘audioOutputDeviceChangeWithInfo’) 监听,当识别到连接的播放设备时,音频流会自动切换到目标设备上。当播放设备断开时,播放器内部不会自动暂停。

2、如果应用想实现自己的设备切换规则,可以注册 on(‘audioOutputDeviceChangeWithInfo’) 监听,交由应用自行处理业务逻辑。

播放设备切换场景:

  • 新播放设备连接
    新设备上线时,系统会触发播放设备自动切换,无需应用主动处理。例如当手机使用扬声器播放视频时,连接到耳机后,会自动选择连接的耳机作为播放设备。

新设备来连接时,应用通过注册的audioOutputDeviceChangeWithInfo监听事件,获取到播放设备变更的原因为AudioStreamDeviceChangeReason.REASON_NEW_DEVICE_AVAILABLE,同时系统会默认切换到新设备上继续播放。如果应用对改场景有特殊需求,可在回调中自行处理业务逻辑。

this.avPlayer.on('audioOutputDeviceChangeWithInfo', (data: audio.AudioStreamDeviceChangeInfo) => {
  if (data.changeReason === audio.AudioStreamDeviceChangeReason.REASON_NEW_DEVICE_AVAILABLE) {
    hilog.info(0x0001, TAG, `Device connect: ${data.changeReason}`);
  }
});
  • 旧播放设备断开

当正在使用的播放设备断开时,应用可以根据使用场景选择暂停视频播放。例如:当断开连接的耳机时,视频暂停。

此时应用通过audioOutputDeviceChangeWithInfo监听事件,获取到设备变更原因为AudioStreamDeviceChangeReason.REASON_OLD_DEVICE_UNAVAILABLE,此处系统不会将播放器自动暂停,应用可以结合业务自行处理需求规则,如暂停视频播放。

this.avPlayer.on('audioOutputDeviceChangeWithInfo', (data: audio.AudioStreamDeviceChangeInfo) => {
  if (data.changeReason === audio.AudioStreamDeviceChangeReason.REASON_OLD_DEVICE_UNAVAILABLE) {
    hilog.info(0x0001, TAG, `Device break: ${data.changeReason}`);
    this.pauseVideo();
  }
});
  • 用户强制选择设备

    用户从界面选择切换音频流输出设备时,系统会自动选择新的设备播放,audioOutputDeviceChangeWithInfo监听事件获取到的变更原因为AudioStreamDeviceChangeReason.REASON_OVERRODE,应用结合业务自行处理。本篇示例未涉及该场景,只在此处进行说明。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值