HarmonyOS 鸿蒙Next续播

HarmonyOS 鸿蒙Next续播 不同组件间切换时,无法实现无缝续播如何解决

4 回复

【背景知识】 AVPlayer集成了流媒体和本地资源解析,媒体资源解封装,视频解码和渲染功能,适用于对媒体资源进行端到端播放的场景,可直接播放mp4、mkv等格式的视频文件。 AVPlayer需要从XComponent组件获取并设置surfaceId属性,用于设置显示画面。surfaceId属性支持在initialized状态下设置,支持在prepared/playing/paused/completed/stopped状态下重新设置,重新设置时确保已经在initialized状态下进行设置,否则重新设置失败,重新设置后视频播放在新的窗口渲染。

【解决方案】 将AVPlayer放进全局Map中,AVPlayer指定相应组件的surfaceId即可实现跨页面视频播放无缝转场。具体操作步骤如下:

  1. 在page1页面,通过GlobalContext将AVPlayer当做全局单例变量放到Map<string, media.AVPlayer>里面,通过router跳转到page2页面。
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { resourceManager } from '@kit.LocalizationKit';

export class GlobalContext {
  private static instance: GlobalContext;
  private _objects = new Map<string, media.AVPlayer>();

  private constructor() {
  }

  public static getContext(): GlobalContext {
    if (!GlobalContext.instance) {
      GlobalContext.instance = new GlobalContext();
    }
    return GlobalContext.instance;
  }

  getObject(value: string): media.AVPlayer | undefined {
    return this._objects.get(value);
  }

  setObject(key: string, objectClass: media.AVPlayer): void {
    this._objects.set(key, objectClass);
  }
}

@Entry
@Component
struct Page1 {
  private surfaceID: string = '';
  private avPlayer: media.AVPlayer | undefined = undefined;
  private mXComponentController: XComponentController = new XComponentController();

  async setMediaAsset(rawFilePath: string) {
    if (this.avPlayer === undefined) {
      console.error(`set media asset, avplayer is undefined`);
      return;
    }
    let context = this.getUIContext().getHostContext()!;
    let resMgr = context.resourceManager;
    let rawFd: resourceManager.RawFileDescriptor | undefined = undefined;
    try {
      rawFd = resMgr.getRawFdSync(rawFilePath);
    } catch (err) {
      console.error(`get raw file description failed: ${err}`);
      return;
    }
    let avFd: media.AVFileDescriptor = { fd: rawFd.fd, offset: rawFd.offset, length: rawFd.length };
    this.avPlayer.fdSrc = avFd;
  }

  private setEventListening() {
    this.avPlayer?.on('error', async (err: BusinessError) => {
      console.error(`AVPlayer error: ${JSON.stringify(err)}`);
      try {
        await this.avPlayer?.reset();
      } catch (error) {
        console.error(`failed to invoke avplyer.reset: ${JSON.stringify(error)}`);
      }
    });
    this.avPlayer?.on('stateChange', async (state: media.AVPlayerState) => {
      console.info(`AVPlayer state change to ${state}`);
      if (this.avPlayer === undefined) {
        console.error('AVPlayer is undefined when state change');
        return;
      }
      switch (state) {
        case 'idle':
          break;
        case 'initialized':
          this.avPlayer.surfaceId = this.surfaceID;
          try {
            await this.avPlayer.prepare();
          } catch (error) {
            console.info(`failed to invoke avplayer.prepare: ${JSON.stringify(error)}`);
          }
          break;
        case 'prepared':
          try {
            await this.avPlayer.play();
          } catch (error) {
            console.info(`failed to invoke avplayer.play: ${JSON.stringify(error)}`);
          }
          break;
        case 'playing':
          break;
        case 'completed':
          break;
        case 'paused':
          break;
        case 'stopped':
          break;
        case 'released':
          break;
        case 'error':
          break;
        default:
          console.info(`AVPlayer change to unknown state: ${state}`);
          break;
      }
    });
  };

  onJumpClick(): void {
    this.getUIContext().getRouter().pushUrl({
      url: 'pages/Page2',
    }, (err) => {
      if (err) {
        console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
        return;
      }
      console.info(`Invoke pushUrl succeeded`);
    });
  }

  build() {
    Column({ space: 20 }) {
      Text('Page1')
        .fontSize(30)
        .fontWeight(FontWeight.Bold);

      XComponent({
        type: XComponentType.SURFACE,
        controller: this.mXComponentController,
      })
        .width('100%')
        .aspectRatio(1)
        .renderFit(RenderFit.RESIZE_CONTAIN)
        .onLoad(async () => {
          this.surfaceID = this.mXComponentController.getXComponentSurfaceId();
          try {
            this.avPlayer = await media.createAVPlayer();
            GlobalContext.getContext().setObject('value', this.avPlayer);
            this.setEventListening();
          } catch (err) {
            console.error(`failed to initialize avplayer`);
            return;
          }
          await this.setMediaAsset('video.mp4');
        });

      Button('Jump Next')
        .padding(5)
        .fontSize(30)
        .onClick(() => {
          this.onJumpClick();
        });
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}
  1. 在page2页面,通过Map<string, media.AVPlayer>获取单例AVPlayer,将page2页面的XComponent的SurfaceId设置给AVPlayer。
import { media } from '@kit.MediaKit';
import { GlobalContext } from './Page1';

@Entry
@Component
struct Page2 {
  private surfaceID: string = '';
  private avPlayer: media.AVPlayer | undefined = undefined;
  private mXComponentController: XComponentController = new XComponentController();

  build() {
    Column({ space: 20 }) {
      Text('Page2')
        .fontSize(30)
        .fontWeight(FontWeight.Bold);

      XComponent({
        type: XComponentType.SURFACE,
        controller: this.mXComponentController,
      })
        .width('100%')
        .aspectRatio(1)
        .renderFit(RenderFit.RESIZE_CONTAIN)
        .onLoad(() => {
          this.surfaceID = this.mXComponentController.getXComponentSurfaceId();
          this.avPlayer = GlobalContext.getContext().getObject('value');
          if (this.avPlayer) {
            this.avPlayer.surfaceId = this.surfaceID;
          }
        });
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}

更多关于HarmonyOS 鸿蒙Next续播的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


【背景知识】

Interface (AVPlayer):播放管理类,用于管理和播放媒体资源。在调用AVPlayer的方法前,需要先通过createAVPlayer()构建一个AVPlayer实例。

XComponent:提供用于图形绘制和媒体数据写入的Surface,XComponent负责将其嵌入到视图中,支持应用自定义Surface位置和大小。

【参考方案】

可参考视频列表页跳转到详情页无缝播放示例,通过AVPlayerXComponent实现视频播放,通过切换AVPlayer的surfaceId控制不同XComponent播放视频实现转场效果,基于Window实现视频全屏播放。

  1. 自定义AVPlayer类,封装AVPlayer,使用XComponent播放视频。

    XComponent({ type: XComponentType.SURFACE, controller: this.mXComponentController })
      .onLoad(() => {
        this.surfaceId = this.mXComponentController.getXComponentSurfaceId();
        this.player.setSurfaceID(this.surfaceId);
        this.player.avPlayerFdSrc(this.getUIContext().getHostContext()!);
      })
    
  2. 自定义播放类中设置AVPlayer状态回调函数,在监听资源播放当前时间回调中放入新的surfaceId。

    // 注册avplayer回调函数
    setAVPlayerCallback(avPlayer: media.AVPlayer) {
      // 监听资源播放当前时间回调函数
      avPlayer.on('timeUpdate', (time: number) => {
        this.currentTime = time;
        avPlayer.surfaceId = this.surfaceID;
      });  
    }
    
  3. XComponent加载时设置新的surfaceID,并通过触发AVPlayer状态回调更换播放组件。

    XComponent()
      .onLoad(() => {
        this.surfaceId = this.xComponentController.getXComponentSurfaceId();
        this.player.setSurfaceID(this.surfaceId);
      })
    
  4. 结合setWindowLayoutFullScreensetPreferredOrientation方法实现视频全屏播放。

    let windowClass: window.Window = windowStage.getMainWindowSync(); // 获取应用主窗口
    // 设置窗口全屏
    let isLayoutFullScreen = true;
    windowClass.setWindowLayoutFullScreen(isLayoutFullScreen).then(() => {
      // ...
    });
    // 设置窗口方向
    export function setWindowDirection(orientation: number, ctx: Context) {
      window.getLastWindow(ctx).then((win) => {
        win.setPreferredOrientation(orientation).then((data) => {
          // ...
        });
      })
    }
    

HarmonyOS Next的续播功能基于分布式能力实现。应用通过AVSession接口管理媒体会话,系统会自动记录播放状态和进度。当用户切换设备时,鸿蒙的分布式数据管理会将播放信息同步到新设备,实现跨设备无缝续播。该功能需要应用适配AVSession框架,并在同一华为帐号下的设备间运行。

在HarmonyOS Next中,不同组件(如Video组件)间切换时实现无缝续播,核心在于状态管理与媒体会话的跨组件/页面生命周期管理。这并非组件自身功能,而是需要开发者通过ArkTS状态管理和媒体控制API来主动实现。

关键方案与步骤

  1. 全局状态管理

    • 将当前播放的媒体源(src)、播放位置(currentTime)、播放状态(playing/paused) 等关键信息,提升到全局状态(例如使用AppStorageLocalStorage)或通过UIAbilityWant传递。
    • 这样,当从一个包含视频的组件/页面跳转到另一个时,新的组件能立即从全局状态中获取到之前的播放进度和状态。
  2. 媒体会话(AVSession)管理

    • 在第一个组件中创建AVSession,并设置播放状态、元数据(如媒体标题)和播放控制命令(播放、暂停、跳转)。
    • 在组件销毁(aboutToDisappear)前,不要销毁AVSession,而是将其控制权移交或在全局状态中保持其关键信息(如sessionId)。
    • 在第二个组件中,尝试获取或恢复之前的AVSession(例如通过sessionId),或基于全局状态信息创建一个新的AVSession并恢复到指定进度。
  3. 具体实现流程

    • 组件A(播放中)
      • 创建AVSession,开始播放。
      • 监听播放时间更新,并实时同步到全局状态(如AppStorage)。
      • 在跳转前,保存AVSessionsessionId到全局状态。
    • 组件B(需续播)
      • aboutToAppear或初始化时,从全局状态获取sessionId和保存的播放位置。
      • 尝试通过avSession.getAVSessionById(sessionId)获取已有会话,或直接基于媒体源和保存的位置创建新会话
      • 调用avSession.setAVPlaybackState()将播放状态设置为playing,并设置currentTime为保存的位置,然后开始播放。

示例代码要点

// 全局状态定义(例如在AppStorage中)
AppStorage.setOrCreate<number>('lastPlayPosition', 0);
AppStorage.setOrCreate<string>('lastSessionId', '');

// 组件A:保存状态
async function savePlayState() {
  let currentPos = videoController.currentTime; // 获取当前播放位置
  AppStorage.set<number>('lastPlayPosition', currentPos);
  AppStorage.set<string>('lastSessionId', avSession.sessionId);
}

// 组件B:恢复播放
async function restorePlayback() {
  let lastPos = AppStorage.get<number>('lastPlayPosition');
  let lastSessionId = AppStorage.get<string>('lastSessionId');
  
  // 尝试获取已有会话或创建新会话
  let session = await avSession.getAVSessionById(lastSessionId);
  if (!session) {
    // 创建新会话,并设置到指定位置
    session = await avSession.createAVSession(...);
  }
  // 设置播放状态和位置
  let playbackState: avSession.AVPlaybackState = {
    state: avSession.PlaybackState.PLAYING,
    currentTime: lastPos
  };
  session.setAVPlaybackState(playbackState);
  videoController.start(); // 开始播放
}

注意事项

  • 后台播放:若需切后台后仍能续播,需申请ohos.permission.KEEP_BACKGROUND_RUNNING权限,并在AVSession中配置后台播放能力。
  • 生命周期协调:确保组件生命周期与AVSession的生命周期正确绑定,避免资源泄露。
  • 状态同步延迟:播放位置需高频更新,建议使用节流(throttle)方式同步到全局状态,以平衡性能与精度。

通过以上方案,即可在HarmonyOS Next中实现跨组件的无缝续播体验。

回到顶部