导览播放到一半,用户回到桌面处理其他事情,暂停按钮仍应控制当前播放的声音。这个需求很容易被实现成两个播放器:主页面保存一份进度,悬浮卡片再维护另一份。窗口看起来相似,播放状态却逐渐分离。

这次用 API 26 的 floatView 实现系统闪控窗,主页面与窗内面板共用 AVPlayer 会话。实验包含授权、显示、桌面暂停、恢复主窗、侧边栏收起和关闭。音源为随包生成的二十秒低音量循环测试音,便于观察播放与暂停,不包含真实展品讲解。

先确认这次接的是哪个窗口

floatView 从 API 26 开始提供,通过 ArkUI Kit 导入。系统管理外部窗口,应用提供内部页面。旧版 floatingBall 是闪控球;两者可以绑定,但独立闪控窗不需要为了显示小窗先申请闪控球权限。

对象本实验使用方式对应职责
floatView圆角矩形模板系统窗口、拖动、收起与关闭
自定义面板作为窗口内容加载展示当前会话并发送命令
AVPlayer服务单例持有播放、暂停及进度回调
AppStorage两个入口共享状态同步标题、会话 ID、状态与进度

窗口启动需要应用处于前台,并取得 FLOAT_VIEW 用户授权。设备支持查询之后,授权、创建、内容加载与启动仍可能失败,错误保留在主页面。约束参考闪控窗开发指南,具体签名按 API 26 SDK 声明核对。

权限通过后再创建控制器

只在用户点击“授权并打开系统闪控窗”时申请权限。拒绝授权不会创建应用内浮层冒充系统窗,也不会清除已经准备好的播放状态。

if (!floatView.isFloatViewEnabled()) {
  throw new Error('当前设备不支持闪控窗');
}
const permission = await abilityAccessCtrl.createAtManager()
  .requestPermissionsFromUser(context, ['ohos.permission.FLOAT_VIEW']);
if (permission.authResults.length !== 1 || permission.authResults[0] !== 0) {
  throw new Error('闪控窗权限未授权,主页面仍可控制播放');
}
const controller = await floatView.create({
  context: context,
  templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
});
this.controller = controller;

工程同时声明对应权限与用途说明。静态声明和动态授权分别承担配置与用户选择,不能省去其中一项。独立窗口没有接入 USE_FLOAT_BALL,也没有修改其他应用的签名或权限。

窗口内容页面需要登记在应用路由配置中。首版读取当前模板限制,使用允许的最大尺寸,避免写死像素值。

controller.onStateChange(this.stateCallback);
await controller.setUIContext('pages/FloatTourPanel');
const limits = floatView.getFloatViewLimits(
  floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
await controller.setWindowSize(limits.maxSize);
AppStorage.set('floatState', '等待 STARTED 回调');
await controller.start();

这里的页面字符串是平台加载内容所需的路由标识。主窗与面板各自创建 UI,播放器不会随页面加载再创建一次。

Promise 返回以后,仍要等待窗口状态

start 的 Promise 返回不能作为“窗口已显示”的唯一依据。Demo 监听 onStateChange,通过 STARTED、IN_SIDEBAR、STOPPED 等状态显示真实过程。回调中的关闭原因可以区分应用主动停止与点击系统标题栏关闭。

this.stateCallback = (info: floatView.FloatViewStateChangeInfo): void => {
  if (this.controller !== controller) { return; }
  AppStorage.set('floatState', `${info.state} ${info.stopReason}`);
  AppStorage.set('floatLive',
    info.state !== floatView.FloatViewState.STOPPED &&
    info.state !== floatView.FloatViewState.ERROR);
  if (info.state === floatView.FloatViewState.STOPPED) {
    controller.offStateChange(this.stateCallback);
    this.controller = undefined;
    this.stateCallback = undefined;
    tourPlayback.pause().catch(() => {});
  }
};

比较当前控制器可阻止旧实例事件改变新窗口状态。收到 STOPPED 后解除监听并清空引用,下次打开时创建新控制器。若保留已经停止的引用,同时又在入口判断“有控制器就直接返回”,窗口关闭一次便无法重开。

本实验选择“关闭系统窗暂停播放”。侧边栏收起只改变显示状态,停止按钮才释放播放器。这样用户关闭浮动控制入口时,不会留下看不到来源的循环测试音。

播放状态来自 AVPlayer 回调

会话服务准备随包 WAV,通过真实 AVPlayer 的 initialized、prepared、playing、paused 等状态推进。页面不会在点击按钮时直接把文字改为“播放中”。进度来自 timeUpdate,不使用独立定时器模拟播放。

player.on('timeUpdate', (position: number) => {
  if (generation === this.generation) {
    AppStorage.set('tourPosition', position);
  }
});
player.on('stateChange', (state: string) => {
  if (generation !== this.generation) { return; }
  AppStorage.set('tourState', state);
  if (state === 'initialized') {
    player.prepare().catch((error: Error) => { finish(error); });
  } else if (state === 'prepared') {
    finish();
  }
});

finish 结束准备等待,并清除准备超时定时器。准备失败时释放播放器并关闭 raw 文件描述符。正常播放期间文件描述符保持有效,停止或切换条目时再关闭。

音源为 48kHz、单声道、16 位 PCM WAV,数据区为 1920000 字节,另加 44 字节头部。播放器音量设为 0.15,这是实验参数,不保证不同设备的听觉响度一致。两个条目使用同一音源,切换重点是会话身份与资源释放。

用明确命令代替 toggle

主窗与面板都发送 pause、resume 或 stop,命令附带 sessionId。服务检查当前会话与执行互斥,再根据真实播放器状态执行。

if (sessionId !== AppStorage.get<string>('tourSessionId') ||
    this.busy || !this.player) {
  return;
}
this.setBusy(true);
try {
  if (action === 'pause' && this.player.state === 'playing') {
    await this.player.pause();
  }
  if (action === 'resume' &&
      (this.player.state === 'paused' || this.player.state === 'prepared')) {
    await this.player.play();
  }
  if (action === 'stop') {
    await this.releasePlayer();
    AppStorage.set('tourState', 'stopped');
  }
} catch (error) {
  AppStorage.set('tourError', (error as Error).message);
} finally {
  this.setBusy(false);
}

已经暂停时再次暂停,不会重新播放。切换到玄龟后,带旧应龙会话 ID 的命令失效。准备阶段关闭窗口时,播放器还不能暂停,服务保存 pauseRequested,准备与播放启动结束后兑现暂停请求。服务测试专门覆盖了这一时序。

两个页面通过 StorageLink 观察相同的 AppStorage 键,按钮调用同一个服务单例;页面不持有播放器。实现中将进度字段命名为 positionMs,避免与 ArkUI 组件已有的 position 属性发生冲突。

桌面暂停、恢复主窗与关闭

2026-09-20,在 HBN-AL80 真机(API 26)上授权并打开系统闪控窗,应龙会话 yinglong-2 进入 playing。暂停后按 Home,桌面上的窗口保持 paused、12.2 秒;点击“回到导览”后,主应用恢复到导览页,窗口仍显示同一会话与暂停位置。

FeatureLab 在真机桌面显示应龙会话,暂停位置为 12.2 秒

点击回到导览后,主应用恢复并保留同一暂停位置

restoreMainWindow 的观察点是主页面恢复。浮窗可以继续保留,因此不能用“浮窗是否消失”判断恢复是否成功。2026-09-19 的 Pura X View API 26 模拟器也取得了相同路径:桌面上的 yinglong-2 暂停在 4.0 秒,返回后主页面与浮窗保持同一会话。

真机操作实际回读对应行为
授权后打开STARTED,状态值 1系统窗启动
暂停并返回桌面yinglong-2,paused,12.2 秒桌面继续显示当前会话
窗内返回导览同一会话、同一暂停位置主页面恢复
收起侧边栏窗口状态 4播放会话继续保留
主页面继续playing主窗控制共享播放器
应用关闭窗口3 APP_STOP,paused应用关闭触发暂停
切换玄龟并重开xuangui-4,playing新会话与控制器重建
标题栏关闭3 TITLE_BAR_STOP_CLICK,paused系统关闭触发暂停
主页面停止stopped结束播放并释放资源

玄龟窗口经标题栏关闭后,主页面回读关闭原因与暂停状态

桌面暂停、主窗恢复、侧边栏和两种关闭入口共同检查了窗口与播放器的状态关系。音源仍是测试信号;接入真实讲解时还需要处理资源内容和播放完成策略。

2026-09-22 在 HBN-AL80 真机重新打开应龙测试音,页面回读会话 yinglong-2 为 playing;点击“授权并打开系统闪控窗”后,系统浮窗单独创建 FloatTourPanel,浮窗内显示同一会话和 playing · 17.4 秒。这次运行补充了当前版本的系统窗证据:

HBN-AL80 真机打开系统闪控窗并显示共享播放会话

接入真实讲解前还要补什么

真实展厅需要建立讲解资源与展品的对应关系,增加字幕、完成状态、下载失效和恢复策略。当前短循环实验的结束由停止按钮控制,不能直接用于无限后台讲解。跨账号切换也要明确停止旧会话。

当前尺寸取自创建时的系统限制。折叠、自由窗口与方向变化可进一步接入 onLimitsChange 和 onRectChange,重新检查面板布局。闪控球绑定需要另一项权限与独立交互验证,当前实验没有覆盖。ERROR 回调与系统异常回收后的恢复也应单独测试;正常关闭后重开不能覆盖所有错误状态。

模拟器曾在启动时返回 1300003 / Failed to get global float view limits,重试后成功,原因尚未定位。遇到该错误时应保留错误码与发生阶段,检查失败后控制器引用和监听是否清理,再允许用户重新打开。

六项服务测试使用系统接口替身验证权限拒绝、启动失败重试、旧会话失效、暂停幂等、准备中关闭及资源清理;模拟器与真机操作负责系统窗与播放状态回读。后续更换设备或扩展真实讲解时,继续保留这种分层验证,便于区分会话逻辑、窗口状态与设备输出问题。

2026-09-23 在 HBN-AL80(API 26)补做当前包复测:应龙测试音启动后,主页面依次回读 playing、paused、恢复 playing、stopped;打开系统闪控窗后,窗内显示同一 yinglong-2 会话。点击系统窗标题栏关闭,主页面回读 3 TITLE_BAR_STOP_CLICK 与 stopped。这确认短测试音的主窗/系统窗共享会话和关闭回收,不覆盖长期后台、异常回收或真实讲解资源。

2026-09-23 真机系统闪控窗

标题栏关闭后的主页面回读

参考:闪控窗开发指南、API 26 SDK 的 floatView 类型声明与 MediaKit AVPlayer 声明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐