附完整源码:VideoContinuationDemo

从手机到平板,视频进度、音量、暂停状态一键迁移,告别手动拖进度条。

一、需求背景

你在客厅用手机看一部在线电影,看到 20 分 35 秒时手机要没电了。你拿起平板,希望它能从刚才的进度继续播放,而不是重新打开应用、找到视频、再拖到 20:35。

这就是跨端迁移的典型场景——视频跨设备续播。HarmonyOS 提供了应用接续能力,让应用可以在不同设备间无缝转移状态。
本篇内容要分享的不是如何做一个视频播放器,而是跨端迁移的核心实现。两台设备无缝衔接播放进度、音量和暂停状态,其实流程是固定的,很简单。
我们采用最轻量的 want.param 方案,代码量少、无需处理大文件,非常适合在线视频类应用。关于写播放器过程中遇到问题也会做分享。

本文你将学到

  • 如何配置应用支持接续
  • 如何在源端保存播放状态
  • 如何在目标端恢复状态并自动 seek
  • 如何实时保存进度以备迁移
  • 如何开启快速拉起,减少等待时间
  • 完整可运行的项目代码

二、方案决策:为什么用 want.param?

HarmonyOS 应用接续有两种主流数据迁移方式:

场景 推荐方案 原因
在线视频(URL + 进度 + 音量)
音乐播放(歌曲ID + 进度)
want.param 数据量小(<100KB),实现简单
本地视频文件(MP4)+ 字幕 分布式对象 + 文件资产 支持大文件同步

决策树

是否需要迁移本地文件?
├─ 是 → 使用分布式对象(后续可拓展)
└─ 否 → 数据总量 <100KB?
        ├─ 是 → 使用 want.param 
        └─ 否 → 使用分布式对象

对于在线视频,迁移数据仅包括:

  • 视频 URL(~100 字节)
  • 播放进度(8 字节)
  • 暂停/播放状态(1 字节)
  • 音量(4 字节)

总大小远小于 100KB,want.param 是最优解。

三、环境要求与使用前提

在开始之前,请确保满足以下所有条件,否则接续图标不会出现或迁移失败:

条件 说明
SDK要求 HarmonyOS API 12 及以上
设备要求 双端设备均为 HarmonyOS 5.0+
账号要求 双端设备登录同一华为账号
网络要求 双端设备打开 WLAN 和蓝牙开关
系统设置 双端设备在“设置 > 多设备协同”中开启 “接续” 功能
应用安装 双端设备均需安装同一应用(签名一致)
数据大小 want.param 传输的数据必须 < 100KB(本方案满足)
模拟器 应用接续功能不支持模拟器,必须在真机上测试

自 API12 起,无需申请 ohos.permission.DISTRIBUTED_DATASYNC 权限。本应用最低支持 API12,因此忽略权限申请步骤。

四、项目结构

VideoContinuationDemo/
├── entry/
│   ├── src/
│   │   ├── main/
│   │   │   ├── ets/
│   │   │   │   ├── entryability/
│   │   │   │   │   └── EntryAbility.ets   # 迁移续接重点都在这
│   │   │   │   ├── pages/
│   │   │   │   │   └── Index.ets          # 播放页面
│   │   │   │   ├── model/
│   │   │   │   │   └── VideoData.ets      # 视频数据模型
│   │   │   │   ├── controller/
│   │   │   │   │   └── AvPlayerController.ets  # 播放控制器
│   │   │   │   ├── utils/
│   │   │   │   │   └── Logger.ets         # 日志工具
│   │   │   │   └── constants/
│   │   │   │       └── CommonConstants.ets    # 常量
│   │   │   └── resources/
│   │   └── module.json5
│   └── build-profile.json5
├── AppScope/
│   ├── app.json5
│   └── resources/
└── build-profile.json5

五、核心代码实现

5.1 常量与日志工具

constants/CommonConstants.ets

export class CommonConstants {
  static readonly CONTINUE_DATA_KEY = 'video_play_state';
  static readonly DEFAULT_VOLUME = 10;
  // 请替换成你自己的视频链接
  static readonly DEFAULT_VIDEO_URL = 'https://media.w3.org/2010/05/sintel/trailer.mp4';
}

utils/Logger.ets(hilog 封装,略)

5.2 数据模型

model/VideoData.ets

export interface PlayState {
  videoUrl: string;      // 在线视频地址
  positionMs: number;    // 播放进度
  isPaused: boolean;     // true=暂停,false=播放中
  volume: number;        // 音量 0-15
}

5.3 播放控制器

controller/AvPlayerController.ets

import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';

const TAG = 'AvPlayerController';

export class AvPlayerController {
  private avPlayer?: media.AVPlayer;
  private surfaceId?: string;
  private onProgressCb?: (pos: number, dur: number) => void;
  private onPlayStateCb?: (isPlaying: boolean) => void;
  private onStateChangeCb?: (state: string) => void;  // 新增:状态变化回调
  private isPrepared: boolean = false;
  private pendingVolume?: number;

  constructor() {}

  async init(surfaceId: string): Promise<void> {
    this.surfaceId = surfaceId;
    try {
      this.avPlayer = await media.createAVPlayer();
      this.setupListeners();
    } catch (error) {
      Logger.error(TAG, `init failed: ${JSON.stringify(error)}`);
      throw new Error(String(error));
    }
  }

  private setupListeners(): void {
    if (!this.avPlayer) return;

    this.avPlayer.on('stateChange', (state: string) => {
      Logger.info(TAG, `State: ${state}`);

      if (state === 'initialized') {
        if (this.surfaceId && this.avPlayer) {
          this.avPlayer.surfaceId = this.surfaceId;
          Logger.info(TAG, `SurfaceId set: ${this.surfaceId}`);
        }
        this.prepare()
      }
      if (state === 'prepared') {
        this.isPrepared = true;
        if (this.pendingVolume !== undefined) {
          this.setVolume(this.pendingVolume);
          this.pendingVolume = undefined;
        }
      }
      if (state === 'playing') {
        this.onPlayStateCb?.(true);
      }
      if (state === 'paused') {
        this.onPlayStateCb?.(false);
      }
      if (state === 'error') {
        this.isPrepared = false;
      }
      // 通知外部状态变化
      this.onStateChangeCb?.(state);
    });

    this.avPlayer.on('timeUpdate', (time: number) => {
      if (this.onProgressCb && this.avPlayer && this.isPrepared) {
        this.onProgressCb(time, this.avPlayer.duration);
      }
    });

    this.avPlayer.on('error', (err: BusinessError) => {
      Logger.error(TAG, `Error: ${err.code}, ${err.message}`);
      this.isPrepared = false;
    });
  }

  /** 注册状态变化回调(外部可监听 initialized, prepared, playing, paused 等) */
  onStateChange(cb: (state: string) => void): void {
    this.onStateChangeCb = cb;
  }

  async setUrl(url: string): Promise<void> {
    if (!this.avPlayer) throw new Error('Player not init');
    try {
      this.avPlayer.url = url;
      Logger.info(TAG, `URL set: ${url}`);
    } catch (error) {
      Logger.error(TAG, `setUrl failed: ${JSON.stringify(error)}`);
      throw new Error(String(error));
    }
  }

  private async prepare(): Promise<void> {
    if (this.avPlayer) {
      try {
        await this.avPlayer.prepare();
      } catch (error) {
        Logger.error(TAG, `prepare failed: ${JSON.stringify(error)}`);
      }
    }
  }

  async play(): Promise<void> {
    if (!this.avPlayer || !this.isPrepared) {
      Logger.warn(TAG, 'play() ignored: not prepared');
      return;
    }
    try {
      await this.avPlayer.play();
    } catch(e) {
      Logger.error(TAG, `play error: ${e}`);
    }
  }

  async pause(): Promise<void> {
    if (!this.avPlayer) return;
    try { await this.avPlayer.pause(); } catch(e) { Logger.error(TAG, `pause error: ${e}`); }
  }

  seek(ms: number): void {
    if (!this.avPlayer || !this.isPrepared) return;
    this.avPlayer.seek(ms)
  }

  setVolume(vol: number): void {
    let rawVol = Math.min(15, Math.max(0, vol));
    if (!this.avPlayer) return;
    const normalized = rawVol / 15;
    if (!this.isPrepared) {
      this.pendingVolume = rawVol;
      Logger.info(TAG, `Volume deferred: ${rawVol}`);
      return;
    }
    try {
      this.avPlayer.setVolume(normalized);
      Logger.info(TAG, `Volume set: ${rawVol} -> ${normalized}`);
    } catch(e) {
      Logger.error(TAG, `volume error: ${e}`);
    }
  }

  onProgress(cb: (pos: number, dur: number) => void): void {
    this.onProgressCb = cb;
  }

  onPlayStateChange(cb: (isPlaying: boolean) => void): void {
    this.onPlayStateCb = cb;
  }

  release(): void {
    this.avPlayer?.release().catch(() => {});
    this.avPlayer = undefined;
    this.isPrepared = false;
    this.pendingVolume = undefined;
  }
}

5.4 EntryAbility(接续核心,支持快速拉起)

entryability/EntryAbility.ets

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { Logger } from '../utils/Logger';
import { CommonConstants } from '../constants/CommonConstants';
import { PlayState } from '../model/VideoData';

const TAG = 'EntryAbility';

export default class EntryAbility extends UIAbility {
  private storage: LocalStorage = new LocalStorage();

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleContinuation(want, launchParam);
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleContinuation(want, launchParam);
  }

  // 统一处理冷启动和热启动的接续逻辑
  private handleContinuation(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 快速拉起:应用提前启动,数据未到达,仅设置加载状态,不恢复窗口
    if (launchParam.launchReason === AbilityConstant.LaunchReason.PREPARE_CONTINUATION) {
      Logger.info(TAG, 'Quick start: app prepared');
      this.storage.setOrCreate('quickStartLoading', true);
      return;
    }

    // 正常接续:数据已到达,解析并保存到 AppStorage
    if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
      Logger.info(TAG, 'Continuation: restoring data');
      try {
        const raw = want.parameters?.[CommonConstants.CONTINUE_DATA_KEY] as string;
        if (raw) {
          const restored = JSON.parse(raw) as PlayState;
          AppStorage.setOrCreate('restoredPlayState', restored);
          AppStorage.setOrCreate('isContinuation', true);
          Logger.info(TAG, `Restore state: ${raw}`);
        }
        this.storage.setOrCreate('quickStartLoading', false);
      } catch (error) {
        Logger.error(TAG, `Parse error: ${error}`);
      }
    }

    // 恢复页面栈(普通启动或正常接续都需要调用,快速拉起已提前 return)
    try {
      this.context.restoreWindowStage(this.storage);
    } catch (error) {
      Logger.error(TAG, `restoreWindowStage failed: ${error}`);
    }
  }

  // 源端:保存迁移数据
  onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
    const state = AppStorage.get<PlayState>('currentPlayState');
    if (!state) {
      return AbilityConstant.OnContinueResult.AGREE;
    }
    wantParam[CommonConstants.CONTINUE_DATA_KEY] = JSON.stringify(state);
    Logger.info(TAG, `Save state: ${JSON.stringify(state)}`);
    return AbilityConstant.OnContinueResult.AGREE;
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index').catch((err: Error) => {
      Logger.error(TAG, `Load content failed: ${JSON.stringify(err)}`);
    });
  }
}

5.5 首页(播放页)

pages/Index.ets

import { AvPlayerController } from '../controller/AvPlayerController';
import { PlayState } from '../model/VideoData';
import { CommonConstants } from '../constants/CommonConstants';
import { Logger } from '../utils/Logger';

const TAG = 'IndexPage';

@Entry
@Component
struct Index {
  @StorageLink('currentPlayState') currentPlayState: PlayState = {
    videoUrl: CommonConstants.DEFAULT_VIDEO_URL,
    positionMs: 0,
    isPaused: false,
    volume: CommonConstants.DEFAULT_VOLUME,
  };
  @StorageLink('restoredPlayState') restoredPlayState: PlayState | null = null;
  @StorageLink('isContinuation') isContinuation: boolean = false;
  @StorageLink('quickStartLoading') quickStartLoading: boolean = false;

  @State duration: number = 0;  // 总时长,单独维护

  private player: AvPlayerController = new AvPlayerController();
  private xcController: XComponentController = new XComponentController();

  private pendingRestore: boolean = false;
  private restorePosition: number = 0;
  private restorePaused: boolean = false;
  private isPrepared: boolean = false;

  aboutToAppear(): void {
    if (this.quickStartLoading) {
      AppStorage.setOrCreate('quickStartLoading', false);
    }
    // 冷启动时必须主动同步 UI 状态
    if (this.isContinuation && this.restoredPlayState) {
      this.currentPlayState.videoUrl = this.restoredPlayState.videoUrl
      this.currentPlayState.isPaused = this.restoredPlayState.isPaused
      this.currentPlayState.positionMs = this.restoredPlayState.positionMs
      this.currentPlayState.volume = this.restoredPlayState.volume

      // 保存恢复参数
      this.pendingRestore = true;
      this.restorePosition = this.restoredPlayState.positionMs;
      this.restorePaused = this.restoredPlayState.isPaused;
      // 清除接续标记
      this.isContinuation = false;
      this.restoredPlayState = null;
      return;
    }
  }

  private onXComponentLoad(): void {
    if (this.isPrepared) return;
    this.isPrepared = true;
    this.initPlayer();
  }

  private async initPlayer(): Promise<void> {
    try {
      const surfaceId = this.xcController.getXComponentSurfaceId();
      if (!surfaceId) {
        Logger.error(TAG, 'surfaceId is empty');
        return;
      }
      await this.player.init(surfaceId);
      // 先设置 URL,播放器开始进入 initialized -> prepared
      await this.player.setUrl(this.currentPlayState.videoUrl);
      this.player.setVolume(this.currentPlayState.volume);
      this.player.onStateChange((state) => {
        if (state === 'prepared') {
          if (this.pendingRestore) {
            // 确保 seek 完成后再决定播放
            this.player.seek(this.restorePosition);
            // 简单延迟等待 seek 生效(或监听 seekDone 事件)
            setTimeout(() => {
              if (!this.restorePaused) {
                this.player.play();
              }
            }, 50);
            this.pendingRestore = false;
          } else {
            this.player.play();
          }
        }
      });
      this.player.onProgress((pos, dur) => {
        this.currentPlayState.positionMs = pos;
        this.duration = dur;
      });
      this.player.onPlayStateChange((playing) => {
        this.currentPlayState.isPaused = !playing;
      });
    } catch (err) {
      Logger.error(TAG, `Init player failed: ${JSON.stringify(err)}`);
    }
  }
  aboutToDisappear(): void {
    this.player.release();
  }

  @Builder
  ProgressBar() {
    Row({ space: 10 }) {
      Text(this.formatTime(this.currentPlayState.positionMs))
        .fontSize(12)
        .width(50)
        .textAlign(TextAlign.Center)
      Slider({
        value: this.currentPlayState.positionMs,
        min: 0,
        max: this.duration,
        step: 1000,
      })
        .layoutWeight(1)
        .onChange((val, mode) => {
          if (mode === SliderChangeMode.End) {
            this.player.seek(val);
          }
        })
      Text(this.formatTime(this.duration))
        .fontSize(12)
        .width(50)
        .fontFamily('monospace')
        .textAlign(TextAlign.Center)
    }
    .width('90%')
    .padding(10)
  }

  @Builder
  ControlButtons() {
    Row({ space: 20 }) {
      Button(this.currentPlayState.isPaused ? '▶️ 播放' : '⏸️ 暂停')
        .onClick(() => {
          if (this.currentPlayState.isPaused) {
            this.player.play();
          } else {
            this.player.pause();
          }
        })
      Button('⏪ 后退10s').onClick(() => {
        this.player.seek(Math.max(0, this.currentPlayState.positionMs - 10000));
      })
      Button('⏩ 前进10s').onClick(() => {
        this.player.seek(Math.min(this.duration, this.currentPlayState.positionMs + 10000));
      })
    }
  }

  @Builder
  VolumeControl() {
    Row() {
      Text('音量').width(50)
      Slider({
        value: this.currentPlayState.volume,
        min: 0,
        max: 15,
        step: 1,
      })
        .layoutWeight(1)
        .onChange((val) => {
          this.currentPlayState.volume = val
          this.player.setVolume(val);
        })
      Text(`${this.currentPlayState.volume}`).width(30)
    }.width('90%').padding(10)
  }

  @Builder
  LoadingOverlay() {
    if (this.quickStartLoading) {
      Column() {
        LoadingProgress().width(48).height(48)
        Text('正在准备迁移...').margin({ top: 12 })
      }
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.7)')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
  }

  build() {
    Stack() {
      Column() {
        XComponent({
          type: XComponentType.SURFACE,
          controller: this.xcController,
        })
          .onLoad(() => this.onXComponentLoad())
          .width('100%')
          .aspectRatio(16 / 9)
          .backgroundColor(Color.Black)

        this.ProgressBar()
        this.ControlButtons()
        this.VolumeControl()
      }
      .width('100%')
      .height('100%')

      this.LoadingOverlay()
    }
  }

  private formatTime(ms: number): string {
    if (ms <= 0) return '00:00';
    const totalSec = Math.floor(ms / 1000);
    const min = Math.floor(totalSec / 60);
    const sec = totalSec % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }
}

六、效果展示

视频跨端续接.gif

七、踩坑经验

  • 有声音无画面surfaceId 必须在 initialized 状态设置且只设置一次,不要在 reset 后重复设置。
  • 错误码 5400102:状态机时序错误,例如在非 initialized 状态调用 prepare。严格按照 createAVPlayerurlinitializedpreparepreparedplay 顺序。
  • 音量设置无效setVolume 参数范围是 0.0~1.0,需要将 UI 层 0~15 归一化。
    -冷启动接续进度异常 未同步 currentPlayState ,确保 aboutToAppear 中同步 restoredPlayStatecurrentPlayState

八、总结

本文通过代码示例,演示了如何使用 want.param 为在线视频应用添加跨端续播功能,并介绍了快速拉起优化体验。核心要点:

  • 数据保存:在源端 onContinue 中将播放状态(URL、进度、暂停状态、音量)序列化为 JSON 存入 wantParam
  • 数据恢复:在目标端 onCreate/onNewWant 中判断 launchReasonCONTINUATION 时,从 want.parameters 中解析 JSON 并存入 AppStorage
  • 页面栈恢复:目标端必须调用 this.context.restoreWindowStage(this.storage) 来恢复系统自动迁移的页面栈和窗口状态(否则页面不会加载)。
  • 状态同步:播放页使用 @StorageLink 实时更新 AppStorage 中的 currentPlayState,确保接续时能保存最新进度。
  • 快速拉起:在 module.json5 中配置 continueType_ContinueQuickStart 后缀,并在 EntryAbility 中处理 PREPARE_CONTINUATION 生命周期,可显著减少大数据迁移时的等待时间。
  • AVPlayer 状态机时序createAVPlayerurlinitialized(设置 surfaceId)→ preparepreparedplay。任何一步顺序错误都会导致 5400102 错误或有声音无画面。
    触发迁移:满足环境要求后,系统会自动在任务中心的应用卡片上显示“迁移”图标,用户点击即可完成迁移,无需应用编写触发代码。

如果你的应用需要迁移本地视频文件(如 MP4)或字幕文件,由于数据量大且涉及文件传输,需要使用分布式对象 + 文件资产迁移方案。我会另外开一篇帖子分享如何实现,这篇文章仅仅提前做了快速拉起。如果本文对你有帮助,欢迎点赞、收藏、转发!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐