14 Angajamente ca22618979 ... bc748a7170

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  onecold bc748a7170 实现音乐卡片功能 4 luni în urmă
  onecold b6d93b76f5 fix(card): skip form updates on snapshot write failure 4 luni în urmă
  onecold a96ca14080 fix(card): adjust test snapshot signature 4 luni în urmă
  onecold b8f559f24d fix(card): store form ids as strings 4 luni în urmă
  onecold 4240002f59 feat(card): add music card manager and stores 4 luni în urmă
  onecold 7a70a51120 fix(card): enforce empty state hygiene 4 luni în urmă
  onecold c178133dba fix(card): normalize empty state and register list tests 4 luni în urmă
  onecold 28e49d6560 fix(card): support timestamps without milliseconds 4 luni în urmă
  onecold 07d14d90ac fix(card): keep resolver output blank when lyrics missing 4 luni în urmă
  onecold 94a89ee425 fix(card): expand lyric parser 4 luni în urmă
  onecold 6d114f34e1 fix(card): tighten music card lyric semantics 4 luni în urmă
  onecold 4147e1baa4 feat(card): add music card snapshot model 4 luni în urmă
  onecold 6bc359a1e9 feat(card): add music card snapshot model 4 luni în urmă
  onecold 5973249912 docs(card): 补充音乐卡片设计 4 luni în urmă
24 a modificat fișierele cu 3250 adăugiri și 10 ștergeri
  1. 548 0
      docs/superpowers/specs/2026-04-02-music-card-design.md
  2. 17 0
      entry/src/main/ets/common/player/MusicCardActionConstants.ets
  3. 2 0
      entry/src/main/ets/common/player/MusicCardConstants.ets
  4. 111 0
      entry/src/main/ets/common/player/MusicCardFormCoverResolver.ets
  5. 160 0
      entry/src/main/ets/common/player/MusicCardFormStore.ets
  6. 414 0
      entry/src/main/ets/common/player/MusicCardManager.ets
  7. 259 0
      entry/src/main/ets/common/player/MusicCardSnapshot.ets
  8. 106 0
      entry/src/main/ets/common/player/MusicCardSnapshotStore.ets
  9. 4 0
      entry/src/main/ets/controller/PlaybackCoordinator.ets
  10. 232 0
      entry/src/main/ets/entryability/EntryAbility.ets
  11. 136 0
      entry/src/main/ets/entryformability/MusicCardFormAbility.ets
  12. 82 6
      entry/src/main/ets/view/LocalMusic.ets
  13. 4 2
      entry/src/main/ets/view/PointLight/PointLightDeFaultButton.ets
  14. 62 0
      entry/src/main/ets/widget/common/MusicPlayerWidgetHelper.ets
  15. 192 0
      entry/src/main/ets/widget/pages/MusicPlayerWidgetCard.ets
  16. 296 0
      entry/src/main/ets/widget/pages/MusicPlayerWidgetLyricCard.ets
  17. 284 0
      entry/src/main/ets/widget/pages/MusicPlayerWidgetWideCard.ets
  18. 18 1
      entry/src/main/module.json5
  19. 25 1
      entry/src/main/resources/base/element/string.json
  20. 64 0
      entry/src/main/resources/base/profile/form_config.json
  21. 10 0
      entry/src/ohosTest/ets/test/List.test.ets
  22. 66 0
      entry/src/ohosTest/ets/test/MusicCardManager.test.ets
  23. 140 0
      entry/src/ohosTest/ets/test/MusicCardSnapshot.test.ets
  24. 18 0
      entry/src/ohosTest/ets/test/PlaybackCoordinator.test.ets

+ 548 - 0
docs/superpowers/specs/2026-04-02-music-card-design.md

@@ -0,0 +1,548 @@
+# 音乐卡片设计
+
+## 背景
+
+当前项目已经具备基础播放控制抽象:
+
+- `MusicPlaybackController` 负责统一播放动作入口。
+- `PlaybackCoordinator` 负责将控制动作转发到实际播放运行时。
+- `PlaybackStateBridge` 已经维护当前歌曲、队列、索引、播放状态、进度与时长。
+
+但项目中还没有音乐卡片能力,`entry/src/main/module.json5` 也尚未声明 `form` 类型扩展能力。
+
+参考实现里有两条路线:
+
+- `KDVideo`:单一音乐播放卡片体系,使用 `FormExtensionAbility + preferences 持久化快照 + updateForm 主动更新`。
+- `musicCard` 官方样例:多类型卡片平台,使用数据库维护卡片实例,并承载播放、歌词、推荐等多类卡片。
+
+结合当前项目结构与近期播放器控制收敛方向,首版音乐卡片应优先复用 `KDVideo` 的轻量链路,而不是引入官方样例那种重型卡片平台。
+
+## 本阶段目标
+
+新增 3 种音乐卡片:
+
+1. 普通播放卡片
+2. 中等播放卡片
+3. 歌词卡片
+
+完成标准:
+
+- 用户可从桌面添加上述 3 种卡片。
+- 三种卡片都能显示当前播放歌曲的封面、标题、歌手和播放状态。
+- 普通卡片和中等卡片支持上一首、播放暂停、下一首。
+- 歌词卡片支持封面、两行歌词、上一首、播放暂停、下一首。
+- 点击卡片主体可回到应用并打开当前播放器视图。
+- 卡片在歌曲切换、播放暂停、进度推进时能同步刷新。
+- 无歌词时,歌词卡片自动回退显示歌名和歌手,不出现空白。
+
+非目标:
+
+- 首版不做拖动进度条。
+- 首版不做收藏、喜欢、推荐歌单等扩展能力。
+- 首版不做整页滚动歌词,仅展示两行歌词。
+- 首版不引入卡片数据库,不建设统一多业务卡片平台。
+- 首版不处理锁屏卡片。
+
+## 方案对比
+
+### 方案一:轻量单链路卡片方案
+
+做法:
+
+- 新增一个 `FormExtensionAbility`。
+- 三种卡片共用一套播放卡片快照和动作协议。
+- 通过 `preferences` 保存快照和 formId 列表。
+- 播放状态变化时主动 `updateForm`。
+
+优点:
+
+- 与当前项目现状最匹配。
+- 对现有播放架构侵入较小。
+- 后续继续推进播放器控制层解耦时不会推翻重来。
+
+缺点:
+
+- 需要补齐卡片基础设施与一层卡片数据适配。
+
+### 方案二:直接依赖 LocalMusic 页面状态
+
+做法:
+
+- 让卡片更新逻辑尽量绑定 `LocalMusic` 页面内部字段和生命周期。
+
+优点:
+
+- 初看接入路径最短。
+
+缺点:
+
+- 页面耦合过重。
+- 一旦 `LocalMusic` 生命周期变化或后续继续拆播放器,卡片很容易失效。
+- 不利于卡片成为稳定的系统级入口。
+
+### 方案三:官方样例式多卡片平台
+
+做法:
+
+- 参考 `musicCard` 样例建设数据库、统一工具类、多类卡片管理框架。
+
+优点:
+
+- 扩展性最好。
+
+缺点:
+
+- 对当前项目明显过重。
+- 第一版投入和改动面过大。
+- 会把“先把播放器卡片做出来”这个目标拖慢。
+
+## 推荐方案
+
+采用方案一,即“轻量单链路卡片方案”。
+
+核心原则:
+
+- 控制动作统一走 `MusicPlaybackController`。
+- 卡片展示数据统一由一套独立的播放卡片快照提供。
+- 卡片实例管理通过 `FormExtensionAbility` 完成。
+- 不把卡片直接绑定到 `LocalMusic` 页面生命周期。
+
+## 目标架构
+
+### 1. FormExtensionAbility
+
+新增 `MusicCardFormAbility`,负责:
+
+- `onAddForm` 时返回初始卡片数据。
+- 记录 formId。
+- `onUpdateForm` 时按最新快照刷新卡片。
+- `onFormEvent` 时处理卡片发回的动作。
+- `onRemoveForm` 时清理失效 formId。
+
+### 2. MusicCardManager
+
+新增统一卡片管理器,负责:
+
+- 从当前播放状态构建卡片快照。
+- 将卡片快照转换为三种卡片共用的 binding data。
+- 主动更新所有已注册 form。
+- 节流歌词卡片刷新频率。
+- 处理 form 更新失败后的 formId 清理。
+
+它是卡片层的唯一更新入口。
+
+### 3. MusicCardSnapshotStore
+
+使用 `preferences` 持久化卡片快照,保证:
+
+- 卡片首次添加时能拿到最近一次播放状态。
+- 应用未完全拉起时仍能显示上次有效内容。
+
+### 4. MusicCardFormStore
+
+使用 `preferences` 保存 formId 列表,负责:
+
+- 新增 formId
+- 删除 formId
+- 读取当前所有 formId
+
+### 5. 现有播放层的职责
+
+`PlaybackStateBridge` 继续承担基础播放状态桥接职责:
+
+- `currentSong`
+- `currentQueue`
+- `currentQueueIndex`
+- `isPlaying`
+- `positionMs`
+- `durationMs`
+
+`LocalMusic` 仍然负责真实播放过程中的状态推进和歌词解析,但它不直接成为卡片宿主。
+
+卡片侧通过 `MusicCardManager` 从现有播放状态中派生专用显示数据。
+
+## 建议文件布局
+
+建议新增以下文件:
+
+- `entry/src/main/ets/entryformability/MusicCardFormAbility.ets`
+- `entry/src/main/ets/common/player/MusicCardConstants.ets`
+- `entry/src/main/ets/common/player/MusicCardSnapshotStore.ets`
+- `entry/src/main/ets/common/player/MusicCardFormStore.ets`
+- `entry/src/main/ets/common/player/MusicCardManager.ets`
+- `entry/src/main/ets/common/player/MusicCardSnapshot.ets`
+- `entry/src/main/ets/widget/pages/MusicPlayerWidgetCard.ets`
+- `entry/src/main/ets/widget/pages/MusicPlayerWidgetWideCard.ets`
+- `entry/src/main/ets/widget/pages/MusicPlayerWidgetLyricCard.ets`
+- `entry/src/main/resources/base/profile/form_config.json`
+
+需要修改的现有文件:
+
+- `entry/src/main/module.json5`
+- `entry/src/main/ets/entryability/EntryAbility.ets`
+- `entry/src/main/ets/view/LocalMusic.ets`
+- 可能还包括现有歌词工具类或封面工具类的少量扩展
+
+## 卡片数据模型
+
+建议新增一份专门的卡片快照,不直接暴露播放器内部所有字段。
+
+建议字段:
+
+- `title`
+- `artist`
+- `coverPath`
+- `hasCoverImage`
+- `isPlaying`
+- `hasSong`
+- `currentPositionMs`
+- `durationMs`
+- `currentTimeText`
+- `durationTimeText`
+- `lyricLine1`
+- `lyricLine2`
+- `hasLyric`
+- `playType`
+- `filePath`
+- `updatedAtMs`
+
+字段原则:
+
+- 卡片只拿自己需要的显示字段。
+- 不把完整队列、复杂页面状态、动画状态塞进卡片快照。
+- 歌词卡片展示只保留两行,避免把整份歌词文本塞进卡片层。
+
+## 三种卡片设计
+
+### 1. 普通播放卡片
+
+定位:
+
+- 紧凑型播放控制卡片。
+
+展示内容:
+
+- 小封面
+- 歌名
+- 歌手
+- 上一首
+- 播放暂停
+- 下一首
+
+交互:
+
+- 点卡片主体:打开应用并进入播放器
+- 点按钮:直接控制播放
+
+说明:
+
+- 第一版不展示进度条。
+- 第一版不展示歌词。
+
+### 2. 中等播放卡片
+
+定位:
+
+- 信息更完整的播放控制卡片。
+
+展示内容:
+
+- 更大的封面
+- 歌名
+- 歌手
+- 可选时间信息或播放状态文案
+- 上一首
+- 播放暂停
+- 下一首
+
+交互:
+
+- 与普通卡片一致
+
+说明:
+
+- 第一版与普通卡片共用同一套动作与数据协议。
+- 差异主要体现在布局和视觉密度。
+
+### 3. 歌词卡片
+
+定位:
+
+- 增强版播放控制卡片。
+
+展示内容:
+
+- 封面
+- 当前歌词
+- 下一句歌词
+- 上一首
+- 播放暂停
+- 下一首
+
+歌词规则:
+
+- 有逐行歌词时:显示当前行和下一行。
+- 只有当前行没有下一行时:第二行留空或回退为歌手名。
+- 无歌词时:回退显示歌名和歌手。
+
+说明:
+
+- 首版不做多行滚动歌词。
+- 首版不做歌词区域滚动动画。
+- 重点保证“准、稳、不卡”。
+
+## 歌词更新策略
+
+歌词卡片是本方案最容易引发频繁刷新的部分,必须节流。
+
+推荐策略:
+
+- 播放进度仍由 `LocalMusic` 在现有链路里持续推进。
+- `LocalMusic` 在推进进度后,调用 `MusicCardManager` 的节流更新入口。
+- `MusicCardManager` 基于当前歌曲和当前进度,解析出“当前行 + 下一行”。
+- 只有歌词行内容发生变化、或播放态发生变化、或歌曲发生变化时,才实际调用 `updateForm`。
+
+建议节流频率:
+
+- 普通状态:800ms 到 1000ms 一次
+- 若歌词行未变化:不更新卡片
+
+这样可以兼顾显示及时性和系统开销。
+
+## 封面策略
+
+封面优先级:
+
+1. 当前歌曲可用的本地封面路径
+2. 已缓存的缩略图路径
+3. 默认占位图
+
+建议复用现有封面能力:
+
+- `CoverThumbCache`
+- 当前歌曲已有的 `pixelMapPath` 或相关封面字段
+
+首版原则:
+
+- 本地和已缓存封面优先
+- 不在卡片层新建复杂下载流程
+- 远端封面若无法稳定直出,则先回退占位图
+
+## 卡片动作协议
+
+三种卡片统一使用一套动作常量。
+
+建议动作:
+
+- `play_pause`
+- `prev_song`
+- `next_song`
+- `open_player`
+- `sync_register`
+
+建议参数:
+
+- `formId`
+- `action`
+- `source`
+
+协议规则:
+
+- 控制按钮通过 `postCardAction(call)` 回到应用。
+- 主体点击通过 `postCardAction(router)` 或统一路由动作打开播放器。
+- `sync_register` 用于卡片出现时补登记 formId,避免首次添加或恢复后 formId 丢失。
+
+## 动作回流链路
+
+推荐链路:
+
+1. 卡片按钮触发 `postCardAction`
+2. `EntryAbility` 接收 call 事件
+3. `EntryAbility` 解析动作
+4. `EntryAbility` 调用 `MusicPlaybackController`
+5. 控制器驱动当前播放运行时执行动作
+6. 播放状态变化后回写 `PlaybackStateBridge`
+7. `MusicCardManager` 刷新所有卡片
+
+主体点击链路:
+
+1. 卡片发送 `open_player`
+2. `EntryAbility` 设置打开播放器所需状态或触发既有事件
+3. 应用拉起后展示当前播放器视图
+
+## 与当前项目的集成点
+
+### 1. module.json5
+
+需要新增一个 `extensionAbilities` 节点:
+
+- 名称建议为 `MusicCardFormAbility`
+- 类型为 `form`
+- 元数据指向 `$profile:form_config`
+
+### 2. form_config.json
+
+需要声明 3 个 form:
+
+- 普通播放卡片
+- 中等播放卡片
+- 歌词卡片
+
+建议都设置为动态卡片,关闭定时更新,走主动刷新:
+
+- `isDynamic: true`
+- `updateEnabled: false`
+
+原因:
+
+- 音乐卡片依赖实时播放状态
+- 主动刷新比定时刷新更准确
+
+### 3. EntryAbility
+
+需要增加:
+
+- 卡片动作 call handler 注册
+- `open_player` 路由处理
+- 必要时对 formId 的兜底登记
+
+### 4. LocalMusic
+
+需要增加:
+
+- 在歌曲切换、播放暂停、进度推进时调用 `MusicCardManager`
+- 在歌词行变化时推动歌词卡片刷新
+
+不建议增加:
+
+- 不把卡片逻辑直接写成大段页面内分支
+- 不让 `LocalMusic` 自己维护 formId 列表
+
+## 分阶段实施建议
+
+### 阶段一:打通基础卡片链路
+
+目标:
+
+- 新增 `FormExtensionAbility`
+- 新增 `form_config.json`
+- 跑通普通播放卡片
+- 跑通按钮控制和打开播放器
+
+验收:
+
+- 可添加普通卡片
+- 点击上一首、播放暂停、下一首生效
+- 切歌后卡片自动刷新
+
+### 阶段二:补齐中等播放卡片
+
+目标:
+
+- 复用同一套数据模型和动作协议
+- 完成中等卡布局
+
+验收:
+
+- 中等卡片可正常显示和控制
+- 与普通卡共用同一套更新链路
+
+### 阶段三:补齐歌词卡片
+
+目标:
+
+- 打通歌词两行提取
+- 实现节流更新
+- 实现歌词缺失回退
+
+验收:
+
+- 歌词卡片随播放进度切换两行歌词
+- 无歌词时稳定显示歌名和歌手
+- 更新频率稳定,不造成明显卡顿
+
+### 阶段四:稳定性清理
+
+目标:
+
+- 清理无效 formId
+- 处理卡片恢复、应用重启、播放中断等场景
+- 统一日志与错误兜底
+
+验收:
+
+- 移除卡片后不会持续报错
+- 应用重启后卡片能显示最近状态
+
+## 风险与应对
+
+### 1. 歌词刷新过频
+
+风险:
+
+- `updateForm` 调用过于频繁,影响性能和稳定性。
+
+应对:
+
+- 对歌词卡片做节流。
+- 仅在歌词行变化时刷新。
+
+### 2. 封面来源不稳定
+
+风险:
+
+- 远端或临时路径封面在卡片中不可直接显示。
+
+应对:
+
+- 首版优先本地缓存图或缩略图。
+- 无法稳定获取时回退占位图。
+
+### 3. 播放器状态与卡片状态分裂
+
+风险:
+
+- 页面内局部状态和卡片快照来源不一致。
+
+应对:
+
+- 卡片只认 `MusicCardManager` 构建的统一快照。
+- 不允许三种卡片各自维护不同的数据来源。
+
+### 4. LocalMusic 耦合仍偏重
+
+风险:
+
+- 卡片更新时机如果分散写在 `LocalMusic` 多处,后续维护成本会很高。
+
+应对:
+
+- 抽一个集中入口,例如 `notifyPlaybackCardStateChanged()`。
+- 歌曲切换、播放态切换、进度推进都收敛到统一通知方法。
+
+## 测试与验证
+
+首版应重点做真机冒烟验证。
+
+必须验证:
+
+- 添加三种卡片是否成功
+- 普通卡片播放控制是否正常
+- 中等卡片播放控制是否正常
+- 歌词卡片是否显示两行歌词
+- 无歌词歌曲是否正确回退
+- 切歌后封面、标题、歌手是否同步
+- 暂停恢复后卡片状态是否同步
+- 点击卡片主体是否能进入播放器
+- 删除卡片后是否仍有 form 更新报错
+- 应用重启后卡片是否能显示最近一次播放状态
+
+## 结论
+
+本项目的音乐卡片应采用“`KDVideo` 轻量链路 + 当前项目播放控制抽象”的组合方案:
+
+- 不直接照搬官方样例的重型多卡片平台
+- 不把卡片强绑到 `LocalMusic` 页面生命周期
+- 先用一套统一快照和统一动作协议支撑 3 种卡片
+
+这样既能尽快落地普通卡、中等卡、歌词卡,又能与当前播放器解耦方向保持一致,避免后续返工。

+ 17 - 0
entry/src/main/ets/common/player/MusicCardActionConstants.ets

@@ -0,0 +1,17 @@
+export class MusicCardActionConstants {
+  static readonly FORM_ID_PARAM_KEY: string = 'ttmusic_music_card_form_id'
+  static readonly ACTION_PARAM_KEY: string = 'ttmusic_music_card_action'
+  static readonly ACTION_SOURCE_KEY: string = 'ttmusic_music_card_source'
+  static readonly ACTION_SOURCE_WIDGET: string = 'music_widget'
+  static readonly CALL_METHOD_HANDLE_ACTION: string = 'ttmusic_music_card_call_action'
+  static readonly SEEK_POSITION_MS_KEY: string = 'ttmusic_music_card_seek_position_ms'
+
+  static readonly ACTION_PLAY_PAUSE: string = 'play_pause'
+  static readonly ACTION_NEXT: string = 'next_song'
+  static readonly ACTION_PREVIOUS: string = 'prev_song'
+  static readonly ACTION_OPEN_PLAYER: string = 'open_player'
+  static readonly ACTION_SEEK_TO: string = 'seek_to'
+
+  static readonly PENDING_ACTION_STORAGE_KEY: string = 'musicCardPendingAction'
+  static readonly PENDING_OPEN_STORAGE_KEY: string = 'musicCardPendingOpenPlayer'
+}

+ 2 - 0
entry/src/main/ets/common/player/MusicCardConstants.ets

@@ -0,0 +1,2 @@
+export const MUSIC_CARD_EMPTY_TITLE = '未在播放'
+export const MUSIC_CARD_EMPTY_ARTIST = '点击打开播放器'

+ 111 - 0
entry/src/main/ets/common/player/MusicCardFormCoverResolver.ets

@@ -0,0 +1,111 @@
+import { fileIo, fileUri } from '@kit.CoreFileKit'
+import { FileUtil, MD5 } from '@pura/harmony-utils'
+import Logger from '../util/Logger'
+
+const TAG = 'MusicCardFormCoverResolver'
+
+export class MusicCardFormCoverData {
+  coverPath: string = ''
+  coverImageName: string = ''
+  hasCoverImage: boolean = false
+  formImages: Record<string, number> = {}
+}
+
+export class MusicCardFormCoverResolver {
+  private static cachedFormCoverPath: string = ''
+  private static cachedFormCoverImageName: string = ''
+  private static cachedFormCoverFile: fileIo.File | undefined = undefined
+
+  static buildFormCoverData(sourcePath: string): MusicCardFormCoverData {
+    const normalizedPath = MusicCardFormCoverResolver.normalizeFormCoverPath(sourcePath)
+    if (normalizedPath === '') {
+      MusicCardFormCoverResolver.releaseCachedFormCoverFile('empty_cover')
+      return new MusicCardFormCoverData()
+    }
+    if (!normalizedPath.startsWith('/')) {
+      MusicCardFormCoverResolver.releaseCachedFormCoverFile('non_local_cover')
+      const data = new MusicCardFormCoverData()
+      data.coverPath = normalizedPath
+      return data
+    }
+    return MusicCardFormCoverResolver.ensureFormMemoryCoverData(normalizedPath)
+  }
+
+  private static normalizeFormCoverPath(sourcePath: string): string {
+    const trimmedPath = sourcePath?.trim() ?? ''
+    if (trimmedPath === '') {
+      return ''
+    }
+    if (trimmedPath.startsWith('http://') ||
+      trimmedPath.startsWith('https://') ||
+      trimmedPath.startsWith('resource://') ||
+      trimmedPath.startsWith('data:')) {
+      return trimmedPath
+    }
+    if (trimmedPath.startsWith('file://')) {
+      const localPath = new fileUri.FileUri(trimmedPath).path
+      if (!localPath || !FileUtil.accessSync(localPath)) {
+        return ''
+      }
+      return localPath
+    }
+    if (trimmedPath.startsWith('/')) {
+      if (!FileUtil.accessSync(trimmedPath)) {
+        return ''
+      }
+      return trimmedPath
+    }
+    return trimmedPath
+  }
+
+  private static ensureFormMemoryCoverData(localPath: string): MusicCardFormCoverData {
+    if (MusicCardFormCoverResolver.cachedFormCoverPath === localPath &&
+      MusicCardFormCoverResolver.cachedFormCoverFile !== undefined &&
+      MusicCardFormCoverResolver.cachedFormCoverImageName !== '') {
+      const cachedImages: Record<string, number> = {}
+      cachedImages[MusicCardFormCoverResolver.cachedFormCoverImageName] =
+        MusicCardFormCoverResolver.cachedFormCoverFile.fd
+      const data = new MusicCardFormCoverData()
+      data.coverPath = localPath
+      data.coverImageName = MusicCardFormCoverResolver.cachedFormCoverImageName
+      data.hasCoverImage = true
+      data.formImages = cachedImages
+      return data
+    }
+
+    MusicCardFormCoverResolver.releaseCachedFormCoverFile('cover_changed')
+    try {
+      const coverFile = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY)
+      const imageName = `music_cover_${MD5.digestSync(localPath)}`
+      MusicCardFormCoverResolver.cachedFormCoverPath = localPath
+      MusicCardFormCoverResolver.cachedFormCoverImageName = imageName
+      MusicCardFormCoverResolver.cachedFormCoverFile = coverFile
+      const formImages: Record<string, number> = {}
+      formImages[imageName] = coverFile.fd
+      const data = new MusicCardFormCoverData()
+      data.coverPath = localPath
+      data.coverImageName = imageName
+      data.hasCoverImage = true
+      data.formImages = formImages
+      return data
+    } catch (error) {
+      Logger.warn(TAG, `open form cover file failed path=${localPath}, error=${error}`)
+      const data = new MusicCardFormCoverData()
+      data.coverPath = localPath
+      return data
+    }
+  }
+
+  private static releaseCachedFormCoverFile(reason: string): void {
+    if (MusicCardFormCoverResolver.cachedFormCoverFile !== undefined) {
+      try {
+        fileIo.closeSync(MusicCardFormCoverResolver.cachedFormCoverFile)
+      } catch (error) {
+        Logger.warn(TAG, `release cached form cover failed reason=${reason}, error=${error}`)
+      }
+    }
+    MusicCardFormCoverResolver.cachedFormCoverFile = undefined
+    MusicCardFormCoverResolver.cachedFormCoverPath = ''
+    MusicCardFormCoverResolver.cachedFormCoverImageName = ''
+  }
+}

+ 160 - 0
entry/src/main/ets/common/player/MusicCardFormStore.ets

@@ -0,0 +1,160 @@
+import data_preferences from '@ohos.data.preferences'
+import { common } from '@kit.AbilityKit'
+import Logger from '../util/Logger'
+
+const TAG = 'MusicCardFormStore'
+const STORE_NAME = 'music_card_form_store'
+const FORM_IDS_KEY = 'music_card_form_ids'
+
+const formatError = (error: Object): string => {
+  const err = error as Error
+  if (err && err.message) {
+    return err.message
+  }
+  return `${error}`
+}
+
+const resolveContext = (context?: common.Context): common.Context | undefined => {
+  if (context) {
+    return context
+  }
+  return AppStorage.get('context') as common.Context | undefined
+}
+
+const resolvePreferences = (context?: common.Context): data_preferences.Preferences | undefined => {
+  const resolvedContext = resolveContext(context)
+  if (!resolvedContext) {
+    Logger.warn(TAG, 'context is undefined')
+    return undefined
+  }
+  try {
+    return data_preferences.getPreferencesSync(resolvedContext, {
+      name: STORE_NAME
+    })
+  } catch (error) {
+    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error as Object)}`)
+    return undefined
+  }
+}
+
+const normalizeFormId = (value: string | number | boolean | Object | undefined): string | null => {
+  if (typeof value === 'string') {
+    const normalized = value.trim()
+    return normalized.length > 0 ? normalized : null
+  }
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return `${value}`
+  }
+  return null
+}
+
+const readFormIdsFromPreferences = (preferences: data_preferences.Preferences): string[] => {
+  let raw = ''
+  try {
+    const value: data_preferences.ValueType = preferences.getSync(FORM_IDS_KEY, '')
+    if (typeof value === 'string') {
+      raw = value
+    }
+  } catch (error) {
+    Logger.error(TAG, `readFormIds failed: ${formatError(error as Object)}`)
+    return []
+  }
+  if (!raw || raw.length === 0) {
+    return []
+  }
+  try {
+    const parsed = JSON.parse(raw) as Array<number | string>
+    if (!Array.isArray(parsed)) {
+      return []
+    }
+    const result: string[] = []
+    for (let i = 0; i < parsed.length; i++) {
+      const normalized = normalizeFormId(parsed[i])
+      if (normalized !== null) {
+        result.push(normalized)
+      }
+    }
+    const uniqueResult: string[] = []
+    for (let i = 0; i < result.length; i++) {
+      if (!uniqueResult.includes(result[i])) {
+        uniqueResult.push(result[i])
+      }
+    }
+    return uniqueResult
+  } catch (error) {
+    Logger.error(TAG, `parseFormIds failed: ${formatError(error as Object)}`)
+    return []
+  }
+}
+
+const writeFormIdsToPreferences = (
+  preferences: data_preferences.Preferences,
+  formIds: string[]
+): void => {
+  const payload = JSON.stringify(formIds ?? [])
+  preferences.putSync(FORM_IDS_KEY, payload)
+  preferences.flush()
+}
+
+export class MusicCardFormStore {
+  static readFormIds(context?: common.Context): string[] {
+    const preferences = resolvePreferences(context)
+    if (!preferences) {
+      Logger.warn(TAG, 'musicCard readFormIds skipped because preferences missing')
+      return []
+    }
+    const formIds = readFormIdsFromPreferences(preferences)
+    Logger.info(TAG, `musicCard readFormIds formIds=${JSON.stringify(formIds)}`)
+    return formIds
+  }
+
+  static addFormId(context: common.Context | undefined, formId: string): void {
+    const preferences = resolvePreferences(context)
+    if (!preferences) {
+      Logger.warn(TAG, `musicCard addFormId skipped because preferences missing formId=${formId}`)
+      return
+    }
+    const normalized = normalizeFormId(formId)
+    if (normalized === null) {
+      Logger.warn(TAG, `musicCard addFormId skipped because invalid formId=${formId}`)
+      return
+    }
+    const current = readFormIdsFromPreferences(preferences)
+    if (current.includes(normalized)) {
+      Logger.info(TAG, `musicCard addFormId ignored duplicate formId=${normalized}`)
+      return
+    }
+    current.push(normalized)
+    try {
+      writeFormIdsToPreferences(preferences, current)
+      Logger.info(TAG, `musicCard addFormId success formId=${normalized}, formIds=${JSON.stringify(current)}`)
+    } catch (error) {
+      Logger.error(TAG, `addFormId failed: ${formatError(error as Object)}`)
+    }
+  }
+
+  static removeFormId(context: common.Context | undefined, formId: string): void {
+    const preferences = resolvePreferences(context)
+    if (!preferences) {
+      Logger.warn(TAG, `musicCard removeFormId skipped because preferences missing formId=${formId}`)
+      return
+    }
+    const normalized = normalizeFormId(formId)
+    if (normalized === null) {
+      Logger.warn(TAG, `musicCard removeFormId skipped because invalid formId=${formId}`)
+      return
+    }
+    const current = readFormIdsFromPreferences(preferences)
+    const next = current.filter((item) => item !== normalized)
+    if (next.length === current.length) {
+      Logger.info(TAG, `musicCard removeFormId ignored missing formId=${normalized}`)
+      return
+    }
+    try {
+      writeFormIdsToPreferences(preferences, next)
+      Logger.info(TAG, `musicCard removeFormId success formId=${normalized}, formIds=${JSON.stringify(next)}`)
+    } catch (error) {
+      Logger.error(TAG, `removeFormId failed: ${formatError(error as Object)}`)
+    }
+  }
+}

+ 414 - 0
entry/src/main/ets/common/player/MusicCardManager.ets

@@ -0,0 +1,414 @@
+import { common } from '@kit.AbilityKit'
+import { formBindingData, formProvider } from '@kit.FormKit'
+import Logger from '../util/Logger'
+import { MusicCardFormCoverResolver } from './MusicCardFormCoverResolver'
+import { VideoItem } from '../../viewmodel/VideoItem'
+import {
+  buildMusicCardBindingData,
+  createEmptyMusicCardSnapshot,
+  MusicCardFormBindingData,
+  MusicCardSnapshot,
+  resolveMusicCardLyricLines
+} from './MusicCardSnapshot'
+import { MusicCardFormStore } from './MusicCardFormStore'
+import { MusicCardSnapshotStore } from './MusicCardSnapshotStore'
+
+const TAG = 'MusicCardManager'
+const DEFAULT_TIME_TEXT = '00:00'
+const MUSIC_CARD_OPEN_PLAYER_ACTION = 'open_player'
+const MUSIC_CARD_PLAY_PAUSE_ACTION = 'play_pause'
+const MUSIC_CARD_PREV_ACTION = 'prev_song'
+const MUSIC_CARD_NEXT_ACTION = 'next_song'
+const MUSIC_CARD_SEEK_ACTION = 'seek_to'
+const LYRIC_PROGRESS_THRESHOLD_MS = 800
+
+const padTime = (value: number): string => {
+  return value < 10 ? `0${value}` : `${value}`
+}
+
+const formatTime = (timeMs: number): string => {
+  if (!Number.isFinite(timeMs) || timeMs <= 0) {
+    return DEFAULT_TIME_TEXT
+  }
+  const totalSeconds = Math.floor(timeMs / 1000)
+  const seconds = totalSeconds % 60
+  const minutes = Math.floor(totalSeconds / 60) % 60
+  const hours = Math.floor(totalSeconds / 3600)
+  if (hours > 0) {
+    return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)}`
+  }
+  return `${padTime(minutes)}:${padTime(seconds)}`
+}
+
+const normalizeTimeMs = (timeMs: number): number => {
+  if (!Number.isFinite(timeMs) || timeMs <= 0) {
+    return 0
+  }
+  return Math.floor(timeMs)
+}
+
+export class MusicCardPlaybackStateOptions {
+  currentSong?: VideoItem = undefined
+  isPlaying: boolean = false
+  positionMs: number = 0
+  durationMs: number = 0
+  lyricText?: string = undefined
+  coverPath?: string = undefined
+  coverImageName?: string = undefined
+}
+
+interface MusicCardActionMessage {
+  action?: string
+  message?: string
+  func?: string
+  seekPositionMs?: string | number
+}
+
+export class MusicCardManager {
+  private static instance: MusicCardManager
+
+  static getInstance(): MusicCardManager {
+    if (!MusicCardManager.instance) {
+      MusicCardManager.instance = new MusicCardManager()
+    }
+    return MusicCardManager.instance
+  }
+
+  static buildSnapshotForTest(
+    song: VideoItem | undefined,
+    isPlaying: boolean,
+    positionMs: number,
+    durationMs: number,
+    coverPath: string
+  ): MusicCardSnapshot {
+    if (!song) {
+      const emptySnapshot = createEmptyMusicCardSnapshot()
+      emptySnapshot.updatedAtMs = Date.now()
+      return emptySnapshot
+    }
+    const snapshot = createEmptyMusicCardSnapshot()
+    snapshot.hasSong = true
+    snapshot.title = song.name || song.fileName || ''
+    snapshot.artist = song.artist ?? ''
+    snapshot.coverPath = coverPath ?? ''
+    snapshot.coverImageName = ''
+    snapshot.hasCoverImage = !!snapshot.coverPath
+    snapshot.isPlaying = Boolean(isPlaying)
+    snapshot.currentPositionMs = positionMs ?? 0
+    snapshot.durationMs = durationMs ?? 0
+    snapshot.currentTimeText = formatTime(snapshot.currentPositionMs)
+    snapshot.durationTimeText = formatTime(snapshot.durationMs)
+    snapshot.filePath = song.filePath ?? ''
+    snapshot.lyricText = song.lyricContent ?? ''
+    snapshot.updatedAtMs = Date.now()
+
+    const lyricLines = resolveMusicCardLyricLines(snapshot.lyricText, snapshot.currentPositionMs)
+    if (lyricLines.hasLyric) {
+      snapshot.lyricLine1 = lyricLines.line1
+      snapshot.lyricLine2 = lyricLines.line2
+      snapshot.hasLyric = true
+    } else {
+      snapshot.lyricLine1 = snapshot.title
+      snapshot.lyricLine2 = snapshot.artist
+      snapshot.hasLyric = false
+    }
+
+    return snapshot
+  }
+
+  static shouldUpdateLyricCardForTest(
+    lastUpdateMs: number,
+    currentPositionMs: number,
+    thresholdMs: number
+  ): boolean {
+    if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) {
+      return true
+    }
+    if (!Number.isFinite(lastUpdateMs) || !Number.isFinite(currentPositionMs)) {
+      return true
+    }
+    return Math.abs(currentPositionMs - lastUpdateMs) >= thresholdMs
+  }
+
+  buildSnapshotFromPlaybackState(options: MusicCardPlaybackStateOptions): MusicCardSnapshot {
+    const currentSong = options.currentSong
+    if (!currentSong) {
+      const emptySnapshot = createEmptyMusicCardSnapshot()
+      emptySnapshot.updatedAtMs = Date.now()
+      return emptySnapshot
+    }
+    const snapshot = createEmptyMusicCardSnapshot()
+    snapshot.hasSong = true
+    snapshot.title = currentSong.name || currentSong.fileName || ''
+    snapshot.artist = currentSong.artist ?? ''
+    snapshot.coverPath = options.coverPath ?? currentSong.pixelMapPath ?? ''
+    snapshot.coverImageName = options.coverImageName ?? ''
+    snapshot.hasCoverImage = snapshot.coverImageName !== '' || snapshot.coverPath !== ''
+    snapshot.isPlaying = Boolean(options.isPlaying)
+    snapshot.currentPositionMs = normalizeTimeMs(options.positionMs)
+    snapshot.durationMs = normalizeTimeMs(options.durationMs)
+    snapshot.currentTimeText = formatTime(snapshot.currentPositionMs)
+    snapshot.durationTimeText = formatTime(snapshot.durationMs)
+    snapshot.filePath = currentSong.filePath ?? ''
+    snapshot.lyricText = options.lyricText ?? currentSong.lyricContent ?? ''
+    snapshot.updatedAtMs = Date.now()
+
+    const lyricLines = resolveMusicCardLyricLines(snapshot.lyricText, snapshot.currentPositionMs)
+    if (lyricLines.hasLyric) {
+      snapshot.lyricLine1 = lyricLines.line1
+      snapshot.lyricLine2 = lyricLines.line2
+      snapshot.hasLyric = true
+    } else {
+      snapshot.lyricLine1 = snapshot.title
+      snapshot.lyricLine2 = snapshot.artist
+      snapshot.hasLyric = false
+    }
+    return snapshot
+  }
+
+  notifyPlaybackStateChanged(
+    context: common.Context | undefined,
+    snapshot: MusicCardSnapshot
+  ): void {
+    if (!snapshot) {
+      Logger.warn(TAG, 'notifyPlaybackStateChanged skipped: snapshot is undefined')
+      return
+    }
+    Logger.info(TAG,
+      `musicCard notifyPlaybackStateChanged title=${snapshot.title}, artist=${snapshot.artist}, ` +
+      `coverImageName=${snapshot.coverImageName}, coverPath=${snapshot.coverPath}, hasCoverImage=${snapshot.hasCoverImage}, ` +
+      `hasSong=${snapshot.hasSong}, isPlaying=${snapshot.isPlaying}, positionMs=${snapshot.currentPositionMs}, ` +
+      `durationMs=${snapshot.durationMs}, lyricLine1=${snapshot.lyricLine1}, lyricLine2=${snapshot.lyricLine2}`)
+    snapshot.updatedAtMs = Date.now()
+    const saved = MusicCardSnapshotStore.writeSnapshot(snapshot, context)
+    if (!saved) {
+      Logger.error(TAG, 'notifyPlaybackStateChanged failed to write snapshot')
+      return
+    }
+    this.updateAllForms(context)
+  }
+
+  notifyProgressTick(
+    context: common.Context | undefined,
+    currentSong: VideoItem | undefined,
+    isPlaying: boolean,
+    positionMs: number,
+    durationMs: number,
+    lyricText?: string,
+    coverPath?: string,
+    coverImageName?: string
+  ): void {
+    const options = new MusicCardPlaybackStateOptions()
+    options.currentSong = currentSong
+    options.isPlaying = isPlaying
+    options.positionMs = positionMs
+    options.durationMs = durationMs
+    options.lyricText = lyricText
+    options.coverPath = coverPath
+    options.coverImageName = coverImageName
+    const nextSnapshot = this.buildSnapshotFromPlaybackState(options)
+    const previousSnapshot = MusicCardSnapshotStore.readSnapshot(context)
+    if (!this.shouldRefreshProgressSnapshot(previousSnapshot, nextSnapshot)) {
+      return
+    }
+    this.notifyPlaybackStateChanged(context, nextSnapshot)
+  }
+
+  async handleFormEvent(
+    context: common.Context | undefined,
+    formId: string,
+    message: string
+  ): Promise<void> {
+    const action = this.resolveCardAction(message)
+    const seekPositionMs = this.resolveSeekPositionMs(message)
+    if (action === '') {
+      Logger.warn(TAG, `handleFormEvent skipped because action empty formId=${formId}`)
+      return
+    }
+    const abilityContext = this.resolveAbilityContext(context)
+    if (!abilityContext?.eventHub) {
+      Logger.warn(TAG, `handleFormEvent skipped because eventHub missing action=${action}, formId=${formId}`)
+      return
+    }
+    Logger.info(TAG, `handleFormEvent action=${action}, formId=${formId}`)
+    abilityContext.eventHub.emit('musicCardActionForward', { action, formId, seekPositionMs })
+  }
+
+  updateAllForms(context?: common.Context): void {
+    const abilityContext = this.resolveAbilityContext(context)
+    if (!abilityContext) {
+      Logger.warn(TAG, 'musicCard updateAllForms skipped because abilityContext missing')
+      return
+    }
+    const formIds = MusicCardFormStore.readFormIds(abilityContext)
+    Logger.info(TAG, `musicCard updateAllForms formIds=${JSON.stringify(formIds)}`)
+    if (formIds.length === 0) {
+      Logger.warn(TAG, 'musicCard updateAllForms skipped because no formIds')
+      return
+    }
+    const snapshot = MusicCardSnapshotStore.readSnapshot(abilityContext)
+    const payload = buildMusicCardBindingData(snapshot)
+    Logger.info(TAG,
+      `musicCard updateAllForms payload title=${payload.title}, artist=${payload.artist}, ` +
+      `coverImageName=${payload.coverImageName}, coverPath=${payload.coverPath}, hasCoverImage=${payload.hasCoverImage}, ` +
+      `hasSong=${payload.hasSong}, isPlaying=${payload.isPlaying}, currentPositionMs=${payload.currentPositionMs}, ` +
+      `durationMs=${payload.durationMs}, lyricLine1=${payload.lyricLine1}, lyricLine2=${payload.lyricLine2}`)
+    for (let i = 0; i < formIds.length; i++) {
+      const formId = formIds[i]
+      if (!formId) {
+        continue
+      }
+      Logger.info(TAG, `musicCard updateAllForms update formId=${formId}`)
+      const formCoverData = MusicCardFormCoverResolver.buildFormCoverData(payload.coverPath)
+      const bindingPayload = new MusicCardFormBindingData()
+      bindingPayload.formId = formId
+      bindingPayload.title = payload.title
+      bindingPayload.artist = payload.artist
+      bindingPayload.coverPath = formCoverData.coverPath
+      bindingPayload.coverImageName = formCoverData.coverImageName
+      bindingPayload.hasCoverImage = formCoverData.hasCoverImage
+      bindingPayload.formImages = formCoverData.formImages
+      bindingPayload.hasSong = payload.hasSong
+      bindingPayload.isPlaying = payload.isPlaying
+      bindingPayload.currentPositionMs = payload.currentPositionMs
+      bindingPayload.durationMs = payload.durationMs
+      bindingPayload.currentTimeText = payload.currentTimeText
+      bindingPayload.durationTimeText = payload.durationTimeText
+      bindingPayload.lyricLine1 = payload.lyricLine1
+      bindingPayload.lyricLine2 = payload.lyricLine2
+      bindingPayload.hasLyric = payload.hasLyric
+      bindingPayload.filePath = payload.filePath
+      bindingPayload.updatedAtMs = payload.updatedAtMs
+      const bindingData = formBindingData.createFormBindingData(bindingPayload)
+      void Promise.resolve(formProvider.updateForm(formId, bindingData)).catch((error: Object): void => {
+        Logger.warn(TAG, `musicCard updateAllForms failed formId=${formId}, error=${error}`)
+        MusicCardFormStore.removeFormId(abilityContext, formId)
+      })
+    }
+  }
+
+  private resolveCardAction(message: string): string {
+    if (!message) {
+      return ''
+    }
+    try {
+      const parsed = JSON.parse(message) as MusicCardActionMessage
+      const directAction = this.readStringField(parsed.action)
+      if (directAction !== '') {
+        return this.normalizeCardAction(directAction)
+      }
+      const messageAction = this.readStringField(parsed.message)
+      if (messageAction !== '') {
+        return this.normalizeCardAction(messageAction)
+      }
+      const funcAction = this.readStringField(parsed.func)
+      if (funcAction !== '') {
+        return this.normalizeCardAction(funcAction)
+      }
+      return ''
+    } catch (_error) {
+      return this.normalizeCardAction(message)
+    }
+  }
+
+  private readStringField(value: string | undefined): string {
+    return value ?? ''
+  }
+
+  private normalizeCardAction(action: string): string {
+    if (action === MUSIC_CARD_PLAY_PAUSE_ACTION) {
+      return action
+    }
+    if (action === MUSIC_CARD_PREV_ACTION) {
+      return action
+    }
+    if (action === MUSIC_CARD_NEXT_ACTION) {
+      return action
+    }
+    if (action === MUSIC_CARD_OPEN_PLAYER_ACTION) {
+      return action
+    }
+    if (action === MUSIC_CARD_SEEK_ACTION) {
+      return action
+    }
+    return ''
+  }
+
+  private resolveSeekPositionMs(message: string): string {
+    if (!message) {
+      return ''
+    }
+    try {
+      const parsed = JSON.parse(message) as MusicCardActionMessage
+      const rawValue = parsed.seekPositionMs
+      if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
+        return `${Math.max(0, Math.floor(rawValue))}`
+      }
+      if (typeof rawValue === 'string') {
+        const normalized = rawValue.trim()
+        return normalized !== '' ? normalized : ''
+      }
+      return ''
+    } catch (_error) {
+      return ''
+    }
+  }
+
+  private shouldRefreshProgressSnapshot(
+    previousSnapshot: MusicCardSnapshot,
+    nextSnapshot: MusicCardSnapshot
+  ): boolean {
+    if (previousSnapshot.hasSong !== nextSnapshot.hasSong) {
+      return true
+    }
+    if (previousSnapshot.filePath !== nextSnapshot.filePath) {
+      return true
+    }
+    if (previousSnapshot.title !== nextSnapshot.title) {
+      return true
+    }
+    if (previousSnapshot.artist !== nextSnapshot.artist) {
+      return true
+    }
+    if (previousSnapshot.coverPath !== nextSnapshot.coverPath) {
+      return true
+    }
+    if (previousSnapshot.coverImageName !== nextSnapshot.coverImageName) {
+      return true
+    }
+    if (previousSnapshot.hasCoverImage !== nextSnapshot.hasCoverImage) {
+      return true
+    }
+    if (previousSnapshot.isPlaying !== nextSnapshot.isPlaying) {
+      return true
+    }
+    if (previousSnapshot.currentTimeText !== nextSnapshot.currentTimeText) {
+      return true
+    }
+    if (previousSnapshot.durationTimeText !== nextSnapshot.durationTimeText) {
+      return true
+    }
+    if (previousSnapshot.lyricLine1 !== nextSnapshot.lyricLine1) {
+      return true
+    }
+    if (previousSnapshot.lyricLine2 !== nextSnapshot.lyricLine2) {
+      return true
+    }
+    if (previousSnapshot.hasLyric !== nextSnapshot.hasLyric) {
+      return true
+    }
+    return MusicCardManager.shouldUpdateLyricCardForTest(
+      previousSnapshot.currentPositionMs,
+      nextSnapshot.currentPositionMs,
+      LYRIC_PROGRESS_THRESHOLD_MS
+    )
+  }
+
+  private resolveAbilityContext(context: common.Context | undefined): common.UIAbilityContext | undefined {
+    const candidate = context as common.UIAbilityContext | undefined
+    if (candidate?.eventHub) {
+      return candidate
+    }
+    return AppStorage.get('context') as common.UIAbilityContext | undefined
+  }
+}

+ 259 - 0
entry/src/main/ets/common/player/MusicCardSnapshot.ets

@@ -0,0 +1,259 @@
+import { MUSIC_CARD_EMPTY_ARTIST, MUSIC_CARD_EMPTY_TITLE } from './MusicCardConstants'
+import LyricUtil from '../util/LyricUtil'
+
+const DEFAULT_TIME_TEXT = '00:00'
+const LYRIC_TIMESTAMP_PATTERN = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g
+
+const isNonEmpty = (value?: string): boolean => {
+  return !!value && value.trim() !== ''
+}
+
+export interface MusicCardSnapshot {
+  title: string
+  artist: string
+  coverPath: string
+  coverImageName: string
+  hasCoverImage: boolean
+  hasSong: boolean
+  isPlaying: boolean
+  currentPositionMs: number
+  durationMs: number
+  currentTimeText: string
+  durationTimeText: string
+  lyricLine1: string
+  lyricLine2: string
+  hasLyric: boolean
+  filePath: string
+  updatedAtMs: number
+  lyricText: string
+}
+
+export interface MusicCardLyricLines {
+  line1: string
+  line2: string
+  hasLyric: boolean
+}
+
+class MusicCardLyricEntry {
+  timeMs: number = 0
+  text: string = ''
+}
+
+class MusicCardLyricTimestamp {
+  timeMs: number = 0
+}
+
+export class MusicCardBindingData {
+  title: string = MUSIC_CARD_EMPTY_TITLE
+  artist: string = MUSIC_CARD_EMPTY_ARTIST
+  coverPath: string = ''
+  coverImageName: string = ''
+  hasCoverImage: boolean = false
+  hasSong: boolean = false
+  isPlaying: boolean = false
+  currentPositionMs: number = 0
+  durationMs: number = 0
+  currentTimeText: string = DEFAULT_TIME_TEXT
+  durationTimeText: string = DEFAULT_TIME_TEXT
+  lyricLine1: string = MUSIC_CARD_EMPTY_TITLE
+  lyricLine2: string = MUSIC_CARD_EMPTY_ARTIST
+  hasLyric: boolean = false
+  filePath: string = ''
+  updatedAtMs: number = 0
+}
+
+export class MusicCardFormBindingData extends MusicCardBindingData {
+  formId: string = ''
+  formImages: Record<string, number> = {}
+}
+
+export function createEmptyMusicCardSnapshot(): MusicCardSnapshot {
+  return {
+    title: '',
+    artist: '',
+    coverPath: '',
+    coverImageName: '',
+    hasCoverImage: false,
+    hasSong: false,
+    isPlaying: false,
+    currentPositionMs: 0,
+    durationMs: 0,
+    currentTimeText: DEFAULT_TIME_TEXT,
+    durationTimeText: DEFAULT_TIME_TEXT,
+    lyricLine1: MUSIC_CARD_EMPTY_TITLE,
+    lyricLine2: MUSIC_CARD_EMPTY_ARTIST,
+    hasLyric: false,
+    filePath: '',
+    updatedAtMs: 0,
+    lyricText: ''
+  }
+}
+
+export function resolveMusicCardLyricMetaText(
+  artist: string,
+  currentLyric: string,
+  hasLyric: boolean
+): string {
+  const safeArtist = artist?.trim() ?? ''
+  const safeCurrentLyric = currentLyric?.trim() ?? ''
+  if (safeArtist !== '' && hasLyric && safeCurrentLyric !== '') {
+    return `${safeArtist} - ${safeCurrentLyric}`
+  }
+  if (safeArtist !== '') {
+    return safeArtist
+  }
+  return safeCurrentLyric
+}
+
+export function resolveMusicCardLyricLines(
+  lyricText: string,
+  positionMs: number
+): MusicCardLyricLines {
+  const normalizedLyricText = LyricUtil.convertLyricToSimpleLrc(lyricText ?? '')
+  const trimmedText = normalizedLyricText ? normalizedLyricText.trim() : ''
+  if (trimmedText === '') {
+    return {
+      line1: '',
+      line2: '',
+      hasLyric: false
+    }
+  }
+
+  const entries: Array<MusicCardLyricEntry> = []
+  const rawLines = trimmedText.split(/\r?\n/)
+  for (let i = 0; i < rawLines.length; i++) {
+    const rawLine = rawLines[i].trim()
+    if (rawLine === '') {
+      continue
+    }
+
+    const timestamps: Array<MusicCardLyricTimestamp> = []
+    let lastMatchEnd = 0
+    LYRIC_TIMESTAMP_PATTERN.lastIndex = 0
+    let match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
+
+    while (match) {
+      const minutes = parseInt(match[1], 10)
+      const seconds = parseInt(match[2], 10)
+      const msPart = match[3]
+      let msValue = 0
+      if (msPart) {
+        msValue = msPart.length === 2 ? parseInt(msPart, 10) * 10 : parseInt(msPart, 10)
+      }
+      const timeMs = minutes * 60 * 1000 + seconds * 1000 + msValue
+
+      const timestamp = new MusicCardLyricTimestamp()
+      timestamp.timeMs = timeMs
+      timestamps.push(timestamp)
+      lastMatchEnd = LYRIC_TIMESTAMP_PATTERN.lastIndex
+      match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
+    }
+
+    if (timestamps.length === 0) {
+      continue
+    }
+
+    const text = rawLine.slice(lastMatchEnd).trim()
+    if (text === '') {
+      continue
+    }
+
+    for (let j = 0; j < timestamps.length; j++) {
+      const entry = new MusicCardLyricEntry()
+      entry.timeMs = timestamps[j].timeMs
+      entry.text = text
+      entries.push(entry)
+    }
+  }
+
+  if (entries.length === 0) {
+    return {
+      line1: '',
+      line2: '',
+      hasLyric: false
+    }
+  }
+
+  entries.sort((lhs, rhs) => lhs.timeMs - rhs.timeMs)
+
+  let currentIndex = -1
+  for (let i = 0; i < entries.length; i++) {
+    if (entries[i].timeMs <= positionMs) {
+      currentIndex = i
+    } else {
+      break
+    }
+  }
+
+  if (currentIndex === -1) {
+    currentIndex = 0
+  }
+
+  const currentLine = entries[currentIndex]
+  const nextLine = entries[currentIndex + 1]
+
+  return {
+    line1: currentLine.text,
+    line2: nextLine ? nextLine.text : '',
+    hasLyric: true
+  }
+}
+
+export function buildMusicCardBindingData(snapshot: MusicCardSnapshot): MusicCardBindingData {
+  const source = snapshot || createEmptyMusicCardSnapshot()
+  const data = new MusicCardBindingData()
+
+  data.hasSong = Boolean(source.hasSong)
+  data.title =
+    data.hasSong && isNonEmpty(source.title) ? source.title : MUSIC_CARD_EMPTY_TITLE
+  data.artist =
+    data.hasSong && isNonEmpty(source.artist) ? source.artist : MUSIC_CARD_EMPTY_ARTIST
+  data.coverPath = source.coverPath ?? ''
+  data.coverImageName = source.coverImageName ?? ''
+  data.hasCoverImage = Boolean(source.hasCoverImage)
+  data.isPlaying = Boolean(source.isPlaying)
+  data.currentPositionMs = source.currentPositionMs ?? 0
+  data.durationMs = source.durationMs ?? 0
+  data.currentTimeText = isNonEmpty(source.currentTimeText)
+    ? source.currentTimeText
+    : DEFAULT_TIME_TEXT
+  data.durationTimeText = isNonEmpty(source.durationTimeText)
+    ? source.durationTimeText
+    : DEFAULT_TIME_TEXT
+  data.filePath = source.filePath ?? ''
+  data.updatedAtMs = source.updatedAtMs ?? 0
+
+  if (!data.hasSong) {
+    data.coverPath = ''
+    data.coverImageName = ''
+    data.hasCoverImage = false
+    data.isPlaying = false
+    data.currentPositionMs = 0
+    data.durationMs = 0
+    data.currentTimeText = DEFAULT_TIME_TEXT
+    data.durationTimeText = DEFAULT_TIME_TEXT
+    data.filePath = ''
+    data.lyricLine1 = data.title
+    data.lyricLine2 = data.artist
+    data.hasLyric = false
+    return data
+  }
+
+  let lyricLine1 = data.title
+  let lyricLine2 = data.artist
+  let hasLyric = false
+
+  const lyricLines = resolveMusicCardLyricLines(source.lyricText, source.currentPositionMs)
+
+  if (lyricLines.hasLyric) {
+    lyricLine1 = lyricLines.line1
+    lyricLine2 = lyricLines.line2
+    hasLyric = true
+  }
+
+  data.lyricLine1 = lyricLine1
+  data.lyricLine2 = lyricLine2
+  data.hasLyric = hasLyric
+
+  return data
+}

+ 106 - 0
entry/src/main/ets/common/player/MusicCardSnapshotStore.ets

@@ -0,0 +1,106 @@
+import data_preferences from '@ohos.data.preferences'
+import { common } from '@kit.AbilityKit'
+import Logger from '../util/Logger'
+import { createEmptyMusicCardSnapshot, MusicCardSnapshot } from './MusicCardSnapshot'
+
+const TAG = 'MusicCardSnapshotStore'
+const STORE_NAME = 'music_card_snapshot_store'
+const SNAPSHOT_KEY = 'music_card_snapshot'
+
+const formatError = (error: Object): string => {
+  const err = error as Error
+  if (err && err.message) {
+    return err.message
+  }
+  return `${error}`
+}
+
+const resolveContext = (context?: common.Context): common.Context | undefined => {
+  if (context) {
+    return context
+  }
+  return AppStorage.get('context') as common.Context | undefined
+}
+
+const resolvePreferences = (context?: common.Context): data_preferences.Preferences | undefined => {
+  const resolvedContext = resolveContext(context)
+  if (!resolvedContext) {
+    Logger.warn(TAG, 'context is undefined')
+    return undefined
+  }
+  try {
+    return data_preferences.getPreferencesSync(resolvedContext, {
+      name: STORE_NAME
+    })
+  } catch (error) {
+    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error as Object)}`)
+    return undefined
+  }
+}
+
+const mergeSnapshot = (parsed: MusicCardSnapshot): MusicCardSnapshot => {
+  const snapshot = createEmptyMusicCardSnapshot()
+  snapshot.title = parsed.title
+  snapshot.artist = parsed.artist
+  snapshot.coverPath = parsed.coverPath
+  snapshot.coverImageName = parsed.coverImageName ?? ''
+  snapshot.hasCoverImage = parsed.hasCoverImage
+  snapshot.hasSong = parsed.hasSong
+  snapshot.isPlaying = parsed.isPlaying
+  snapshot.currentPositionMs = parsed.currentPositionMs
+  snapshot.durationMs = parsed.durationMs
+  snapshot.currentTimeText = parsed.currentTimeText
+  snapshot.durationTimeText = parsed.durationTimeText
+  snapshot.lyricLine1 = parsed.lyricLine1
+  snapshot.lyricLine2 = parsed.lyricLine2
+  snapshot.hasLyric = parsed.hasLyric
+  snapshot.filePath = parsed.filePath
+  snapshot.updatedAtMs = parsed.updatedAtMs
+  snapshot.lyricText = parsed.lyricText
+  return snapshot
+}
+
+export class MusicCardSnapshotStore {
+  static readSnapshot(context?: common.Context): MusicCardSnapshot {
+    const preferences = resolvePreferences(context)
+    if (!preferences) {
+      return createEmptyMusicCardSnapshot()
+    }
+    let raw = ''
+    try {
+      const value: data_preferences.ValueType = preferences.getSync(SNAPSHOT_KEY, '')
+      if (typeof value === 'string') {
+        raw = value
+      }
+    } catch (error) {
+      Logger.error(TAG, `readSnapshot failed: ${formatError(error as Object)}`)
+      return createEmptyMusicCardSnapshot()
+    }
+    if (!raw || raw.length === 0) {
+      return createEmptyMusicCardSnapshot()
+    }
+    try {
+      const parsed = JSON.parse(raw) as MusicCardSnapshot
+      return mergeSnapshot(parsed)
+    } catch (error) {
+      Logger.error(TAG, `parseSnapshot failed: ${formatError(error as Object)}`)
+      return createEmptyMusicCardSnapshot()
+    }
+  }
+
+  static writeSnapshot(snapshot: MusicCardSnapshot, context?: common.Context): boolean {
+    const preferences = resolvePreferences(context)
+    if (!preferences) {
+      return false
+    }
+    try {
+      const payload = JSON.stringify(snapshot ?? createEmptyMusicCardSnapshot())
+      preferences.putSync(SNAPSHOT_KEY, payload)
+      preferences.flush()
+      return true
+    } catch (error) {
+      Logger.error(TAG, `writeSnapshot failed: ${formatError(error as Object)}`)
+      return false
+    }
+  }
+}

+ 4 - 0
entry/src/main/ets/controller/PlaybackCoordinator.ets

@@ -57,6 +57,10 @@ export class PlaybackCoordinator {
     }
   }
 
+  public hasRuntime(): boolean {
+    return this.runtime !== undefined
+  }
+
   /**
    * 请求运行时播放指定队列,并在失败时恢复之前的播放状态。
    *

+ 232 - 0
entry/src/main/ets/entryability/EntryAbility.ets

@@ -13,6 +13,7 @@ import hilog from '@ohos.hilog';
 import window from '@ohos.window';
 import { AbilityConstant, appRecovery, Want } from '@kit.AbilityKit';
 import { BusinessError, emitter } from '@kit.BasicServicesKit';
+import { rpc } from '@kit.IPCKit';
 import { AppUtil, ArrayUtil, GlobalContext, LogUtil, StrUtil } from '@pura/harmony-utils';
 import { DemoConstants } from './DemoConstants';
 import { EventConstants } from '../common/constants/EventConstants';
@@ -29,6 +30,27 @@ import { url } from '@kit.ArkTS';
 import { display } from '@kit.ArkUI';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
+import { PlaybackCoordinator } from '../controller/PlaybackCoordinator';
+import { MusicCardFormStore } from '../common/player/MusicCardFormStore';
+
+const MUSIC_CARD_ACTION_PATTERN: RegExp = /"ttmusic_music_card_action"\s*:\s*"([^"]*)"/;
+const MUSIC_CARD_FORM_ID_STRING_PATTERN: RegExp = /"ttmusic_music_card_form_id"\s*:\s*"([^"]*)"/;
+const MUSIC_CARD_FORM_ID_NUMBER_PATTERN: RegExp = /"ttmusic_music_card_form_id"\s*:\s*(-?\d+)/;
+const MUSIC_CARD_SOURCE_PATTERN: RegExp = /"ttmusic_music_card_source"\s*:\s*"([^"]*)"/;
+const MUSIC_CARD_SEEK_POSITION_STRING_PATTERN: RegExp = /"ttmusic_music_card_seek_position_ms"\s*:\s*"([^"]*)"/;
+const MUSIC_CARD_SEEK_POSITION_NUMBER_PATTERN: RegExp = /"ttmusic_music_card_seek_position_ms"\s*:\s*(-?\d+)/;
+
+class EmptyRpcParcelable implements rpc.Parcelable {
+    marshalling(_dataOut: rpc.MessageSequence): boolean {
+        return true;
+    }
+
+    unmarshalling(_dataIn: rpc.MessageSequence): boolean {
+        return true;
+    }
+}
 /**
  * 主Ability类,继承自UIAbility
  * 负责:
@@ -37,6 +59,58 @@ import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
  * - 事件分发
  */
 export default class EntryAbility extends UIAbility {
+    private readonly musicCardCallHandler = (data: rpc.MessageSequence): rpc.Parcelable => {
+        try {
+            const rawText: string = data.readString() ?? '';
+            const action: string = this.resolveMusicCardActionFromText(rawText);
+            const source: string = this.resolveMusicCardSourceFromText(rawText);
+            const formId: string = this.resolveMusicCardFormIdFromText(rawText);
+            const seekPositionMs: string = this.resolveMusicCardSeekPositionFromText(rawText);
+            this.registerMusicCardFormIdIfNeeded(formId, source);
+            if (StrUtil.isEmpty(action)) {
+                Logger.warn('EntryAbility', `musicCardCallHandler skipped because action empty payload=${rawText}`);
+                return new EmptyRpcParcelable();
+            }
+            this.dispatchMusicCardAction(action, 'call', seekPositionMs);
+        } catch (error) {
+            const err = error as Error;
+            Logger.error('EntryAbility', `musicCardCallHandler failed: ${err.message}`);
+        }
+        return new EmptyRpcParcelable();
+    }
+    private readonly musicCardActionForwardHandler = (data?: Object): void => {
+        const payloadText: string = data ? JSON.stringify(data) : '';
+        const action = this.resolveMusicCardActionFromText(payloadText);
+        const seekPositionMs = this.resolveMusicCardSeekPositionFromText(payloadText);
+        if (StrUtil.isEmpty(action)) {
+            Logger.warn('EntryAbility', 'musicCardActionForward skipped because action empty');
+            return;
+        }
+        Logger.info('EntryAbility', `musicCardActionForward action=${action}`);
+        if (action === 'play_pause') {
+            this.context.eventHub?.emit('playOrPause');
+            return;
+        }
+        if (action === 'prev_song') {
+            this.context.eventHub?.emit('playPrevious');
+            return;
+        }
+        if (action === 'next_song') {
+            this.context.eventHub?.emit('playNext');
+            return;
+        }
+        if (action === 'open_player') {
+            this.context.eventHub?.emit('showPlayerView');
+            return;
+        }
+        if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
+            if (StrUtil.isNotEmpty(seekPositionMs)) {
+                void MusicPlaybackController.getInstance().seekTo(seekPositionMs, 'music-card-event-hub');
+            }
+            return;
+        }
+        Logger.warn('EntryAbility', `musicCardActionForward ignored unsupported action=${action}`);
+    }
     // UI上下文对象,用于获取窗口信息
     private uiContext?: UIContext;
     // private awareness: smartMobilityCommon.SmartMobilityAwareness = smartMobilityCommon.getSmartMobilityAwareness();
@@ -96,6 +170,9 @@ export default class EntryAbility extends UIAbility {
 
     async onCreate(want:Want, launchParam:AbilityConstant.LaunchParam) {
         AppStorage.setOrCreate('context', this.context);
+        this.context.eventHub?.on('musicCardActionForward', this.musicCardActionForwardHandler);
+        this.registerMusicCardCallHandler();
+        this.handleMusicCardActionFromWant(want);
         hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
 
 
@@ -160,12 +237,165 @@ export default class EntryAbility extends UIAbility {
     async onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
         hilog.info(0x0000, 'testTag', `onNewWant, want=${JSON.stringify(want)}`);
         super.onNewWant(want, launchParam);
+        this.handleMusicCardActionFromWant(want);
         this.loadDoWant(want)
         await this.handleParam(want)
         this.handleWeChatCallIfNeed(want)
 
     }
 
+    private handleMusicCardActionFromWant(want: Want): void {
+        const parameters = want.parameters as Object | undefined;
+        if (parameters === undefined) {
+            return;
+        }
+        const parametersText: string = JSON.stringify(parameters);
+        const action: string = this.resolveMusicCardActionFromText(parametersText);
+        const source: string = this.resolveMusicCardSourceFromText(parametersText);
+        const formId: string = this.resolveMusicCardFormIdFromText(parametersText);
+        const seekPositionMs: string = this.resolveMusicCardSeekPositionFromText(parametersText);
+        this.registerMusicCardFormIdIfNeeded(formId, source);
+        if (StrUtil.isEmpty(action)) {
+            return;
+        }
+        this.dispatchMusicCardAction(action, 'want', seekPositionMs);
+    }
+
+    private dispatchMusicCardAction(action: string, source: string, seekPositionMs: string = ''): void {
+        Logger.info('EntryAbility', `dispatch music card action=${action}, source=${source}, seek=${seekPositionMs}`);
+        const playbackController: MusicPlaybackController = MusicPlaybackController.getInstance();
+        const playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance();
+        if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+            if (playbackCoordinator.hasRuntime()) {
+                void playbackController.playOrPause();
+            } else {
+                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+            }
+            return;
+        }
+        if (action === MusicCardActionConstants.ACTION_PREVIOUS) {
+            if (playbackCoordinator.hasRuntime()) {
+                void playbackController.playPrevious();
+            } else {
+                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+            }
+            return;
+        }
+        if (action === MusicCardActionConstants.ACTION_NEXT) {
+            if (playbackCoordinator.hasRuntime()) {
+                void playbackController.playNext();
+            } else {
+                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+            }
+            return;
+        }
+        if (action === MusicCardActionConstants.ACTION_OPEN_PLAYER) {
+            this.routeToMusicPlayerPage();
+            return;
+        }
+        if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
+            if (!playbackCoordinator.hasRuntime()) {
+                Logger.warn('EntryAbility', `music card seek ignored because runtime missing, source=${source}`);
+                return;
+            }
+            if (StrUtil.isEmpty(seekPositionMs)) {
+                Logger.warn('EntryAbility', `music card seek ignored because seek empty, source=${source}`);
+                return;
+            }
+            void playbackController.seekTo(seekPositionMs, 'music-card-widget');
+            return;
+        }
+        Logger.warn('EntryAbility', `music card action ignored unsupported action=${action}, source=${source}`);
+    }
+
+    private registerMusicCardCallHandler(): void {
+        try {
+            this.callee.on(MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION, this.musicCardCallHandler);
+        } catch (error) {
+            const err = error as Error;
+            Logger.warn('EntryAbility', `registerMusicCardCallHandler failed: ${err.message}`);
+        }
+    }
+
+    private unregisterMusicCardCallHandler(): void {
+        try {
+            this.callee.off(MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION);
+        } catch (error) {
+            const err = error as Error;
+            Logger.warn('EntryAbility', `unregisterMusicCardCallHandler failed: ${err.message}`);
+        }
+    }
+
+    private registerMusicCardFormIdIfNeeded(formId: string, source: string): void {
+        if (source !== MusicCardActionConstants.ACTION_SOURCE_WIDGET) {
+            return;
+        }
+        if (StrUtil.isEmpty(formId)) {
+            return;
+        }
+        MusicCardFormStore.addFormId(this.context, formId);
+    }
+
+    private resolveMusicCardActionFromText(payloadText: string): string {
+        if (StrUtil.isEmpty(payloadText)) {
+            return '';
+        }
+        const match = payloadText.match(MUSIC_CARD_ACTION_PATTERN);
+        if (!match || match.length < 2) {
+            return '';
+        }
+        return match[1];
+    }
+
+    private resolveMusicCardSourceFromText(payloadText: string): string {
+        if (StrUtil.isEmpty(payloadText)) {
+            return '';
+        }
+        const match = payloadText.match(MUSIC_CARD_SOURCE_PATTERN);
+        if (!match || match.length < 2) {
+            return '';
+        }
+        return match[1];
+    }
+
+    private resolveMusicCardFormIdFromText(payloadText: string): string {
+        if (StrUtil.isEmpty(payloadText)) {
+            return '';
+        }
+        const stringMatch = payloadText.match(MUSIC_CARD_FORM_ID_STRING_PATTERN);
+        if (stringMatch && stringMatch.length > 1) {
+            return stringMatch[1].trim();
+        }
+        const numberMatch = payloadText.match(MUSIC_CARD_FORM_ID_NUMBER_PATTERN);
+        if (numberMatch && numberMatch.length > 1) {
+            return `${numberMatch[1]}`;
+        }
+        return '';
+    }
+
+    private resolveMusicCardSeekPositionFromText(payloadText: string): string {
+        if (StrUtil.isEmpty(payloadText)) {
+            return '';
+        }
+        const stringMatch = payloadText.match(MUSIC_CARD_SEEK_POSITION_STRING_PATTERN);
+        if (stringMatch && stringMatch.length > 1) {
+            return stringMatch[1].trim();
+        }
+        const numberMatch = payloadText.match(MUSIC_CARD_SEEK_POSITION_NUMBER_PATTERN);
+        if (numberMatch && numberMatch.length > 1) {
+            return `${numberMatch[1]}`;
+        }
+        return '';
+    }
+
+    private routeToMusicPlayerPage(): void {
+        if (PlaybackCoordinator.getInstance().hasRuntime()) {
+            this.context.eventHub?.emit('showPlayerView');
+            return;
+        }
+        AppStorage.setOrCreate(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY, true);
+    }
+
     private handleWeChatCallIfNeed(want: Want) {
         WXApi.handleWant(want, WXEventHandler)
     }
@@ -222,6 +452,8 @@ export default class EntryAbility extends UIAbility {
 
     onDestroy() {
         try {
+        this.context.eventHub?.off('musicCardActionForward', this.musicCardActionForwardHandler);
+        this.unregisterMusicCardCallHandler();
         hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
             if(this.awareness){
                 let types: smartMobilityCommon.SmartMobilityType[] = [smartMobilityCommon.SmartMobilityType.CAR_HOP];

+ 136 - 0
entry/src/main/ets/entryformability/MusicCardFormAbility.ets

@@ -0,0 +1,136 @@
+import { Want } from '@kit.AbilityKit'
+import { formBindingData, FormExtensionAbility, formInfo, formProvider } from '@kit.FormKit'
+import { MusicCardManager } from '../common/player/MusicCardManager'
+import { MusicCardFormCoverResolver } from '../common/player/MusicCardFormCoverResolver'
+import { buildMusicCardBindingData, MusicCardFormBindingData } from '../common/player/MusicCardSnapshot'
+import { MusicCardFormStore } from '../common/player/MusicCardFormStore'
+import { MusicCardSnapshotStore } from '../common/player/MusicCardSnapshotStore'
+import Logger from '../common/util/Logger'
+
+const TAG = 'MusicCardFormAbility'
+const FORM_ID_PARAM_KEY = 'ohos.extra.param.key.form_identity'
+const FORM_ID_STRING_PATTERN = /"ohos\.extra\.param\.key\.form_identity"\s*:\s*"([^"]*)"/
+const FORM_ID_NUMBER_PATTERN = /"ohos\.extra\.param\.key\.form_identity"\s*:\s*(-?\d+)/
+const FORM_ID_TRUE_PATTERN = /"ohos\.extra\.param\.key\.form_identity"\s*:\s*true/
+const FORM_ID_FALSE_PATTERN = /"ohos\.extra\.param\.key\.form_identity"\s*:\s*false/
+
+export default class MusicCardFormAbility extends FormExtensionAbility {
+  onAddForm(want: Want): formBindingData.FormBindingData {
+    const formId = this.resolveFormIdFromWant(want)
+    Logger.info(TAG, `musicCard onAddForm formId=${formId}`)
+    MusicCardFormStore.addFormId(this.context, formId)
+    return formBindingData.createFormBindingData(this.buildPayload(formId))
+  }
+
+  onUpdateForm(formId: string): void {
+    Logger.info(TAG, `musicCard onUpdateForm formId=${formId}`)
+    MusicCardFormStore.addFormId(this.context, formId)
+    const bindingData = formBindingData.createFormBindingData(this.buildPayload(formId))
+    void Promise.resolve(formProvider.updateForm(formId, bindingData)).catch((error: Object): void => {
+      Logger.warn(TAG, `onUpdateForm failed formId=${formId}, error=${this.formatError(error)}`)
+    })
+  }
+
+  onFormEvent(formId: string, message: string): void {
+    Logger.info(TAG, `musicCard onFormEvent formId=${formId}, message=${message}`)
+    MusicCardFormStore.addFormId(this.context, formId)
+    void MusicCardManager.getInstance()
+      .handleFormEvent(this.context, formId, message)
+      .catch((error: Object): void => {
+        Logger.warn(TAG, `onFormEvent failed formId=${formId}, error=${this.formatError(error)}`)
+      })
+  }
+
+  onRemoveForm(formId: string): void {
+    MusicCardFormStore.removeFormId(this.context, formId)
+  }
+
+  onAcquireFormState(_want: Want): number {
+    return formInfo.FormState.READY
+  }
+
+  private buildPayload(formId: string): MusicCardFormBindingData {
+    const snapshot = MusicCardSnapshotStore.readSnapshot(this.context)
+    const source = buildMusicCardBindingData(snapshot)
+    Logger.info(TAG,
+      `musicCard buildPayload formId=${formId}, title=${source.title}, artist=${source.artist}, ` +
+      `coverImageName=${source.coverImageName}, coverPath=${source.coverPath}, hasCoverImage=${source.hasCoverImage}, ` +
+      `hasSong=${source.hasSong}, isPlaying=${source.isPlaying}, currentPositionMs=${source.currentPositionMs}, ` +
+      `durationMs=${source.durationMs}, lyricLine1=${source.lyricLine1}, lyricLine2=${source.lyricLine2}`)
+    const formCoverData = MusicCardFormCoverResolver.buildFormCoverData(source.coverPath)
+    const payload = new MusicCardFormBindingData()
+    payload.formId = formId
+    payload.title = source.title
+    payload.artist = source.artist
+    payload.coverPath = formCoverData.coverPath
+    payload.coverImageName = formCoverData.coverImageName
+    payload.hasCoverImage = formCoverData.hasCoverImage
+    payload.formImages = formCoverData.formImages
+    payload.hasSong = source.hasSong
+    payload.isPlaying = source.isPlaying
+    payload.currentPositionMs = source.currentPositionMs
+    payload.durationMs = source.durationMs
+    payload.currentTimeText = source.currentTimeText
+    payload.durationTimeText = source.durationTimeText
+    payload.lyricLine1 = source.lyricLine1
+    payload.lyricLine2 = source.lyricLine2
+    payload.hasLyric = source.hasLyric
+    payload.filePath = source.filePath
+    payload.updatedAtMs = source.updatedAtMs
+    return payload
+  }
+
+  private resolveFormIdFromWant(want: Want): string {
+    const parameters = want.parameters as Object | undefined
+    if (!parameters) {
+      return ''
+    }
+    const formIdValue = this.readFormIdValue(parameters)
+    const formId = this.normalizeFormId(formIdValue)
+    if (formId === '' && formIdValue !== undefined) {
+      Logger.warn(TAG, `resolveFormIdFromWant received unsupported formId=${this.formatError(formIdValue)}`)
+    }
+    return formId
+  }
+
+  private readFormIdValue(parameters: Object): string | number | boolean | Object | undefined {
+    const parametersText = JSON.stringify(parameters)
+    if (!parametersText || parametersText.indexOf(FORM_ID_PARAM_KEY) < 0) {
+      return undefined
+    }
+
+    const stringMatch = parametersText.match(FORM_ID_STRING_PATTERN)
+    if (stringMatch && stringMatch.length > 1) {
+      return stringMatch[1]
+    }
+
+    const numberMatch = parametersText.match(FORM_ID_NUMBER_PATTERN)
+    if (numberMatch && numberMatch.length > 1) {
+      return Number(numberMatch[1])
+    }
+
+    if (FORM_ID_TRUE_PATTERN.test(parametersText)) {
+      return true
+    }
+
+    if (FORM_ID_FALSE_PATTERN.test(parametersText)) {
+      return false
+    }
+
+    return parametersText
+  }
+
+  private normalizeFormId(value: string | number | boolean | Object | undefined): string {
+    if (typeof value === 'string') {
+      return value.trim()
+    }
+    if (typeof value === 'number' && Number.isSafeInteger(value)) {
+      return `${value}`
+    }
+    return ''
+  }
+
+  private formatError(error: Object): string {
+    return `${error}`
+  }
+}

+ 82 - 6
entry/src/main/ets/view/LocalMusic.ets

@@ -79,6 +79,8 @@ import { PlayingIndicator } from './PlayingIndicator';
 import { PointLightButton } from './PointLight/PointLightButton';
 import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton'
 import { PlayConstants } from '../common/constants/PlayConstants';
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants';
+import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager'
 import { effectKit } from '@kit.ArkGraphics2D';
 import { ColorConversion } from '../common/util/ColorConversion';
 import { Lyric, LyricController, LyricParser, LyricView2 } from '@seagazer/cclyric';
@@ -265,7 +267,10 @@ export interface WorkerMetadataPayload {
 
 interface WorkerMessageScanDone {
   code: 101;
-  data: Record<string, never>;
+  data: WorkerMessageScanDoneData;
+}
+
+interface WorkerMessageScanDoneData {
 }
 
 interface WorkerMessageMediaList {
@@ -312,6 +317,10 @@ interface SongPlaybackPreparationResult {
   loadingShown: boolean;
 }
 
+interface OpenLocalSpecialListEventData {
+  target?: string;
+}
+
 type LocalMusicWorkerMessage =
   WorkerMessageScanDone
     | WorkerMessageMediaList
@@ -1083,9 +1092,42 @@ export struct LocalMusic {
       this.doSwipBack()
     })
   }
+
+  private consumePendingMusicCardAction(): void {
+    const pendingAction = AppStorage.get(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY) as string | undefined
+    if (!pendingAction || pendingAction.length === 0) {
+      return
+    }
+    AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, '')
+    if (pendingAction === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+      void this.playbackController.playOrPause()
+      return
+    }
+    if (pendingAction === MusicCardActionConstants.ACTION_PREVIOUS) {
+      void this.playbackController.playPrevious()
+      return
+    }
+    if (pendingAction === MusicCardActionConstants.ACTION_NEXT) {
+      void this.playbackController.playNext()
+    }
+  }
+
+  private consumePendingMusicCardOpenRequest(): void {
+    const pendingOpen = AppStorage.get(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY) as boolean | undefined
+    if (pendingOpen !== true) {
+      return
+    }
+    AppStorage.setOrCreate(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY, false)
+    if (!this.isShowPlay) {
+      this.setShowPlayTrue()
+    }
+    this.showPlayerView()
+  }
   // 组件生命周期
   aboutToAppear() {
     this.playbackCoordinator.setRuntime(this.playbackRuntime)
+    this.consumePendingMusicCardAction()
+    this.consumePendingMusicCardOpenRequest()
 
     console.info('onecold aboutToAppear sdkApiVersion = '+deviceInfo.sdkApiVersion)
     if(ArrayUtil.isNotEmpty(this.videoLocalList)&&this.modeType==0){
@@ -1165,8 +1207,8 @@ export struct LocalMusic {
 
     let eventOpenLocalSpecialList: emitter.InnerEvent = { eventId: EventConstants.EVENT_OPEN_LOCAL_SPECIAL_LIST }
     emitter.on(eventOpenLocalSpecialList, (eventData: emitter.EventData) => {
-      const data = eventData.data as Record<string, Object>
-      const target = data?.target as string
+      const data = eventData.data as OpenLocalSpecialListEventData | undefined
+      const target = data?.target ?? ''
       this.openSpecialLocalList(target)
     });
 
@@ -17537,6 +17579,24 @@ export struct LocalMusic {
     return this.mIjkMediaPlayer.getCurrentPosition()
   }
 
+  private notifyPlaybackCardStateChanged(): void {
+    try {
+      const context = this.getUIContext().getHostContext() as common.UIAbilityContext
+      const options = new MusicCardPlaybackStateOptions()
+      options.currentSong = this.currentSong
+      options.isPlaying = this.CONTROL_PlayStatus === PlayStatus.PLAY
+      options.positionMs = this.getActivePlaybackPositionMs()
+      options.durationMs = this.getActiveDuration()
+      options.lyricText = this.lyricContent
+      options.coverPath = this.currentSong?.pixelMapPath ?? this.cover ?? ''
+      options.coverImageName = ''
+      const snapshot = MusicCardManager.getInstance().buildSnapshotFromPlaybackState(options)
+      MusicCardManager.getInstance().notifyPlaybackStateChanged(context, snapshot)
+    } catch (error) {
+      Logger.warn(TAG, `notifyPlaybackCardStateChanged failed: ${error}`)
+    }
+  }
+
   private syncLyricPositionNow(): void {
     const position = this.getActivePlaybackPositionMs()
     if (position < 0) {
@@ -17694,6 +17754,20 @@ export struct LocalMusic {
     } catch (error) {
       // setProgress 频率较高,这里仅做兜底避免影响主流程
     }
+    try {
+      MusicCardManager.getInstance().notifyProgressTick(
+        this.getUIContext().getHostContext() as common.UIAbilityContext,
+        this.currentSong,
+        this.CONTROL_PlayStatus === PlayStatus.PLAY,
+        position,
+        duration,
+        this.lyricContent,
+        this.currentSong?.pixelMapPath ?? this.cover ?? '',
+        ''
+      )
+    } catch (_error) {
+      // 卡片刷新不能影响主播放流程
+    }
   }
 
   private startProgressTask() {
@@ -17782,6 +17856,7 @@ export struct LocalMusic {
     }
     this.name = song.name
     this.artist = song.artist
+    this.notifyPlaybackCardStateChanged()
   }
 
   private applySelectedQueueSong(index: number): void {
@@ -18106,9 +18181,9 @@ export struct LocalMusic {
         const webdavManager = RemoteDriveManager.getInstance();
         const baiduHeaders = await webdavManager.buildHttpHeadersWithAccountId(this.currentSong, this.videoUrl);
         baiduHeaders.forEach((value, key) => headers.set(key, value));
-        const headerSnapshot: Record<string, string> = {};
-        headers.forEach((value, key) => headerSnapshot[key] = value);
-        Logger.info(TAG, `Baidu播放请求头: ${JSON.stringify(headerSnapshot)}`);
+        const headerSnapshotList: string[] = [];
+        headers.forEach((value, key) => headerSnapshotList.push(`${key}=${value}`));
+        Logger.info(TAG, `Baidu播放请求头: ${headerSnapshotList.join('; ')}`);
       } catch (error) {
         Logger.error(`heanup 构建百度请求头失败: ${(error as Error).message}`);
       }
@@ -18974,6 +19049,7 @@ export struct LocalMusic {
     } catch (error) {
       Logger.warn(TAG, `回写 PlaybackStateBridge 失败: ${error}`)
     }
+    this.notifyPlaybackCardStateChanged()
   }
 
   private setPlaybackStateChangeListener(): void {

+ 4 - 2
entry/src/main/ets/view/PointLight/PointLightDeFaultButton.ets

@@ -13,6 +13,8 @@ export struct PointLightDefaultButton{
   @Require@Prop@Watch('setButtonSize') builderWidth: number
   // 可选
   public buttonScale: number = 1.3
+  public symbolSize: number = 20
+  public imageSize: number = 20
   @State pointLightHeight: number = 100
   public canShadow: boolean = true
   public canPointLight: boolean = true
@@ -87,13 +89,13 @@ export struct PointLightDefaultButton{
   imageBuilder(){
     if(this.isSysBol){
       SymbolGlyph(this.imageResource as Resource)
-        .fontSize(20)
+        .fontSize(this.symbolSize)
         .fontColor([this.pointColor])
         .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
         .alignSelf(ItemAlign.Center)
     }else{
       Image(this.imageResource)
-        .width(20)
+        .width(this.imageSize)
         .fillColor(this.pointColor)
         .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.5})
         .aspectRatio(1)

+ 62 - 0
entry/src/main/ets/widget/common/MusicPlayerWidgetHelper.ets

@@ -0,0 +1,62 @@
+import { resolveMusicCardLyricMetaText } from '../../common/player/MusicCardSnapshot'
+
+export function resolveMusicCardWidgetCoverSource(
+  coverImageName: string,
+  coverPath: string,
+  hasCoverImage: boolean
+): string | Resource {
+  const safeCoverImageName = coverImageName?.trim() ?? ''
+  if (hasCoverImage && safeCoverImageName !== '') {
+    return `memory://${safeCoverImageName}`
+  }
+  const safeCoverPath = coverPath?.trim() ?? ''
+  if (safeCoverPath !== '') {
+    return safeCoverPath
+  }
+  return $r('app.media.nocover')
+}
+
+export function resolveMusicCardWidgetLyricMeta(
+  artist: string,
+  currentLyric: string,
+  hasLyric: boolean
+): string {
+  return resolveMusicCardLyricMetaText(artist, currentLyric, hasLyric)
+}
+
+export function resolveMusicCardWidgetNextLyric(
+  nextLyric: string,
+  hasLyric: boolean
+): string {
+  if (!hasLyric) {
+    return ''
+  }
+  return nextLyric?.trim() ?? ''
+}
+
+export function clampMusicCardWidgetProgress(
+  currentPositionMs: number,
+  durationMs: number
+): number {
+  const safeDuration = Number.isFinite(durationMs) ? Math.max(0, Math.floor(durationMs)) : 0
+  const safePosition = Number.isFinite(currentPositionMs) ? Math.max(0, Math.floor(currentPositionMs)) : 0
+  if (safeDuration <= 0) {
+    return 0
+  }
+  return Math.min(safePosition, safeDuration)
+}
+
+export function formatMusicCardWidgetTime(timeMs: number): string {
+  const safeTimeMs = Number.isFinite(timeMs) ? Math.max(0, Math.floor(timeMs)) : 0
+  const totalSeconds = Math.floor(safeTimeMs / 1000)
+  const seconds = totalSeconds % 60
+  const minutes = Math.floor(totalSeconds / 60) % 60
+  const hours = Math.floor(totalSeconds / 3600)
+  const pad = (value: number): string => {
+    return value < 10 ? `0${value}` : `${value}`
+  }
+  if (hours > 0) {
+    return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
+  }
+  return `${pad(minutes)}:${pad(seconds)}`
+}

+ 192 - 0
entry/src/main/ets/widget/pages/MusicPlayerWidgetCard.ets

@@ -0,0 +1,192 @@
+import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
+import Logger from '../../common/util/Logger'
+import {
+  clampMusicCardWidgetProgress,
+  resolveMusicCardWidgetCoverSource
+} from '../common/MusicPlayerWidgetHelper'
+
+const cardStorage: LocalStorage = new LocalStorage()
+const ENTRY_ABILITY_NAME = 'EntryAbility'
+const TAG = 'MusicPlayerWidgetCard'
+
+@Entry(cardStorage)
+@Component
+struct MusicPlayerWidgetCard {
+  @LocalStorageProp('formId') formId: string = ''
+  @LocalStorageProp('title') title: string = '未在播放'
+  @LocalStorageProp('artist') artist: string = '点击打开播放器'
+  @LocalStorageProp('coverPath') coverPath: string = ''
+  @LocalStorageProp('coverImageName') coverImageName: string = ''
+  @LocalStorageProp('hasCoverImage') hasCoverImage: boolean = false
+  @LocalStorageProp('hasSong') hasSong: boolean = false
+  @LocalStorageProp('isPlaying') isPlaying: boolean = false
+  @LocalStorageProp('currentPositionMs') @Watch('syncSliderFromPlayback') currentPositionMs: number = 0
+  @LocalStorageProp('durationMs') @Watch('syncSliderFromPlayback') durationMs: number = 0
+  @LocalStorageProp('currentTimeText') currentTimeText: string = '00:00'
+  @LocalStorageProp('durationTimeText') durationTimeText: string = '00:00'
+
+  @State sliderProgressMs: number = 0
+
+  aboutToAppear(): void {
+    Logger.info(TAG,
+      `musicCard small aboutToAppear formId=${this.formId}, title=${this.title}, artist=${this.artist}, ` +
+      `coverImageName=${this.coverImageName}, coverPath=${this.coverPath}, hasCoverImage=${this.hasCoverImage}, ` +
+      `hasSong=${this.hasSong}, isPlaying=${this.isPlaying}, currentPositionMs=${this.currentPositionMs}, ` +
+      `durationMs=${this.durationMs}`)
+    this.syncSliderFromPlayback()
+  }
+
+  private syncSliderFromPlayback(): void {
+    this.sliderProgressMs = clampMusicCardWidgetProgress(this.currentPositionMs, this.durationMs)
+  }
+
+  private postControlAction(action: string): void {
+    Logger.info(TAG, `musicCard small postControlAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'call',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        method: MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION,
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private postRouterAction(action: string): void {
+    Logger.info(TAG, `musicCard small postRouterAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'router',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private resolveCoverSource(): string | Resource {
+    return resolveMusicCardWidgetCoverSource(this.coverImageName, this.coverPath, this.hasCoverImage)
+  }
+
+  private resolveProgressValue(): number {
+    if (this.durationMs <= 0) {
+      return 0
+    }
+    return Math.min(100, Math.max(0, Math.floor(this.sliderProgressMs * 100 / this.durationMs)))
+  }
+
+  private resolveTitleArtistText(): string {
+    const safeTitle = this.title?.trim() ?? ''
+    const safeArtist = this.artist?.trim() ?? ''
+    if (safeTitle !== '' && safeArtist !== '') {
+      return `${safeTitle} - ${safeArtist}`
+    }
+    if (safeTitle !== '') {
+      return safeTitle
+    }
+    if (safeArtist !== '') {
+      return safeArtist
+    }
+    return '未在播放'
+  }
+
+  private resolvePlayPauseIcon(): Resource {
+    return this.isPlaying ? $r('sys.symbol.pause_fill') : $r('sys.symbol.play_fill')
+  }
+
+  @Builder
+  private ControlButton(imageResource: Resource, action: string, buttonSize: number, symbolSize: number) {
+    Button() {
+      SymbolGlyph(imageResource)
+        .fontSize(symbolSize)
+        .fontColor([Color.White])
+        .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+    }
+    .width(buttonSize * 1.35)
+    .height(buttonSize * 1.35)
+    .backgroundColor(Color.Transparent)
+    .type(ButtonType.Circle)
+    .stateEffect(true)
+    .onClick(() => this.postControlAction(action))
+  }
+
+  @Builder
+  private PlayProgressButton() {
+    Stack({ alignContent: Alignment.Center }) {
+      Progress({ value: this.resolveProgressValue(), total: 100, type: ProgressType.Ring })
+        .width(34)
+        .height(34)
+        .color(Color.White)
+        .style({ strokeWidth: 2 })
+
+      Button() {
+        SymbolGlyph(this.resolvePlayPauseIcon())
+          .fontSize(19)
+          .fontColor([Color.White])
+          .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+      }
+      .width(34)
+      .height(34)
+      .type(ButtonType.Circle)
+      .backgroundColor('#26FFFFFF')
+      .stateEffect(false)
+      .onClick(() => this.postControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE))
+    }
+    .width(34)
+    .height(34)
+  }
+
+  build() {
+    Stack() {
+      Image(this.resolveCoverSource())
+        .width('100%')
+        .height('100%')
+        .objectFit(ImageFit.Cover)
+
+      Column()
+        .width('100%')
+        .height('100%')
+        .linearGradient(
+          { direction: GradientDirection.Right, colors:
+          [['#00BC70',0.0],['#D81B60',1.0]]})
+
+      Column({ space: 12 }) {
+        Image(this.resolveCoverSource())
+          .width(60)
+          .height(60)
+          .borderRadius(6)
+          .objectFit(ImageFit.Cover)
+
+        Text(this.resolveTitleArtistText())
+          .fontSize(13)
+          .fontWeight(FontWeight.Bold)
+          .fontColor(Color.White)
+          .maxLines(1)
+          .textAlign(TextAlign.Center)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+        Row({ space: 16 }) {
+          this.ControlButton($r('sys.symbol.backward_end_fill'),
+            MusicCardActionConstants.ACTION_PREVIOUS, 18, 18)
+          this.PlayProgressButton()
+          this.ControlButton($r('sys.symbol.forward_end_fill'),
+            MusicCardActionConstants.ACTION_NEXT, 18, 18)
+        }
+        .justifyContent(FlexAlign.Center)
+        .alignItems(VerticalAlign.Center)
+      }
+      .width('100%')
+      .height('100%')
+      .alignItems(HorizontalAlign.Center)
+      .justifyContent(FlexAlign.Center)
+      .padding({ left: 16, right: 16, top: 16, bottom: 14 })
+    }
+    .width('100%')
+    .height('100%')
+    .clip(true)
+    .onClick(() => this.postRouterAction(MusicCardActionConstants.ACTION_OPEN_PLAYER))
+  }
+}

+ 296 - 0
entry/src/main/ets/widget/pages/MusicPlayerWidgetLyricCard.ets

@@ -0,0 +1,296 @@
+import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
+import Logger from '../../common/util/Logger'
+import {
+  clampMusicCardWidgetProgress,
+  formatMusicCardWidgetTime,
+  resolveMusicCardWidgetCoverSource,
+  resolveMusicCardWidgetLyricMeta,
+  resolveMusicCardWidgetNextLyric
+} from '../common/MusicPlayerWidgetHelper'
+
+const lyricCardStorage: LocalStorage = new LocalStorage()
+const ENTRY_ABILITY_NAME = 'EntryAbility'
+const SLIDER_CHANGE_MODE_END = 2
+const TAG = 'MusicPlayerWidgetLyricCard'
+
+@Entry(lyricCardStorage)
+@Component
+struct MusicPlayerWidgetLyricCard {
+  @LocalStorageProp('formId') formId: string = ''
+  @LocalStorageProp('title') title: string = '未在播放'
+  @LocalStorageProp('artist') artist: string = '点击打开播放器'
+  @LocalStorageProp('coverPath') coverPath: string = ''
+  @LocalStorageProp('coverImageName') coverImageName: string = ''
+  @LocalStorageProp('hasCoverImage') hasCoverImage: boolean = false
+  @LocalStorageProp('hasSong') hasSong: boolean = false
+  @LocalStorageProp('isPlaying') isPlaying: boolean = false
+  @LocalStorageProp('currentPositionMs') @Watch('syncSliderFromPlayback') currentPositionMs: number = 0
+  @LocalStorageProp('durationMs') @Watch('syncSliderFromPlayback') durationMs: number = 0
+  @LocalStorageProp('currentTimeText') currentTimeText: string = '00:00'
+  @LocalStorageProp('durationTimeText') durationTimeText: string = '00:00'
+  @LocalStorageProp('lyricLine1') lyricLine1: string = '未在播放'
+  @LocalStorageProp('lyricLine2') lyricLine2: string = '点击打开播放器'
+  @LocalStorageProp('hasLyric') hasLyric: boolean = false
+
+  @State isDragging: boolean = false
+  @State dragProgressMs: number = 0
+  @State sliderProgressMs: number = 0
+
+  aboutToAppear(): void {
+    Logger.info(TAG,
+      `musicCard lyric aboutToAppear formId=${this.formId}, title=${this.title}, artist=${this.artist}, ` +
+      `coverImageName=${this.coverImageName}, coverPath=${this.coverPath}, hasCoverImage=${this.hasCoverImage}, ` +
+      `hasSong=${this.hasSong}, isPlaying=${this.isPlaying}, currentPositionMs=${this.currentPositionMs}, ` +
+      `durationMs=${this.durationMs}, lyricLine1=${this.lyricLine1}, lyricLine2=${this.lyricLine2}, hasLyric=${this.hasLyric}`)
+    this.syncSliderFromPlayback()
+  }
+
+  private syncSliderFromPlayback(): void {
+    if (this.isDragging) {
+      return
+    }
+    this.sliderProgressMs = clampMusicCardWidgetProgress(this.currentPositionMs, this.durationMs)
+  }
+
+  private postControlAction(action: string): void {
+    Logger.info(TAG, `musicCard lyric postControlAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'call',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        method: MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION,
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private postSeekAction(positionMs: number): void {
+    Logger.info(TAG, `musicCard lyric postSeekAction formId=${this.formId}, positionMs=${positionMs}`)
+    postCardAction(this, {
+      action: 'call',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        method: MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION,
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: MusicCardActionConstants.ACTION_SEEK_TO,
+        ttmusic_music_card_seek_position_ms: `${Math.max(0, Math.floor(positionMs))}`,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private postRouterAction(action: string): void {
+    Logger.info(TAG, `musicCard lyric postRouterAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'router',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private resolveCoverSource(): string | Resource {
+    return resolveMusicCardWidgetCoverSource(this.coverImageName, this.coverPath, this.hasCoverImage)
+  }
+
+  private resolveSliderValue(): number {
+    if (this.isDragging) {
+      return this.dragProgressMs
+    }
+    return this.sliderProgressMs
+  }
+
+  private resolveLyricMetaText(): string {
+    return resolveMusicCardWidgetLyricMeta(this.artist, this.lyricLine1, this.hasLyric)
+  }
+
+  private resolveNextLyricText(): string {
+    return resolveMusicCardWidgetNextLyric(this.lyricLine2, this.hasLyric)
+  }
+
+  private resolveCurrentTimeText(): string {
+    if (this.isDragging) {
+      return formatMusicCardWidgetTime(this.dragProgressMs)
+    }
+    return this.currentTimeText
+  }
+
+  private handleSliderChange(value: number, mode: SliderChangeMode): void {
+    const nextValue = clampMusicCardWidgetProgress(value, this.durationMs)
+    if (mode !== SLIDER_CHANGE_MODE_END) {
+      Logger.info(TAG, `musicCard lyric slider dragging formId=${this.formId}, value=${nextValue}, mode=${mode}`)
+      this.isDragging = true
+      this.dragProgressMs = nextValue
+      return
+    }
+    Logger.info(TAG, `musicCard lyric slider end formId=${this.formId}, value=${nextValue}, mode=${mode}`)
+    this.dragProgressMs = nextValue
+    this.sliderProgressMs = nextValue
+    this.isDragging = false
+    this.postSeekAction(nextValue)
+  }
+
+  @Builder
+  private ControlButton(imageResource: Resource, action: string, buttonSize: number, symbolSize: number) {
+    Button({ type: ButtonType.Circle, stateEffect: true }) {
+      SymbolGlyph(imageResource)
+        .fontSize(symbolSize)
+        .fontColor([Color.White])
+        .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+    }
+    .width(buttonSize * 1.35)
+    .height(buttonSize * 1.35)
+    .backgroundColor(Color.Transparent)
+    .onClick(() => this.postControlAction(action))
+  }
+
+  private hasCoverBackground(): boolean {
+    if (this.hasCoverImage && this.coverImageName.length > 0) {
+      return true;
+    }
+    return this.coverPath.length > 0;
+  }
+
+  private resolveTitleArtistText(): string {
+    const safeTitle = this.title?.trim() ?? ''
+    const safeArtist = this.artist?.trim() ?? ''
+    if (safeTitle !== '' && safeArtist !== '') {
+      return `${safeTitle} - ${safeArtist}`
+    }
+    if (safeTitle !== '') {
+      return safeTitle
+    }
+    if (safeArtist !== '') {
+      return safeArtist
+    }
+    return '未在播放'
+  }
+
+  build() {
+    Stack({ alignContent: Alignment.Center }) {
+
+      if (this.hasCoverBackground()) {
+        Column()
+          .width('100%')
+          .height('100%')
+          .backgroundImage(this.resolveCoverSource())
+          .backgroundImageSize({ width: '100%' })
+          .backgroundBlurStyle(BlurStyle.BACKGROUND_REGULAR)
+      } else {
+        Column()
+          .width('100%')
+          .height('100%')
+          .linearGradient( { direction: GradientDirection.Right, colors:[
+            ['#00BC70',0.0],['#D81B60',1.0]]})
+      }
+
+
+
+      Row({ space: 16 }) {
+        Image(this.resolveCoverSource())
+          .width(120)
+          .height(120)
+          .borderRadius(10)
+          .objectFit(ImageFit.Cover)
+
+        Column({ space: 8 }) {
+          Text(this.resolveTitleArtistText())
+            .fontSize(16)
+            .fontWeight(FontWeight.Bold)
+            .fontColor(Color.White)
+            .maxLines(1)
+            .textAlign(TextAlign.Center)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+          Text(this.resolveLyricMetaText())
+            .fontSize(12)
+            .fontColor('#F2FFFFFF')
+            .maxLines(1)
+            .textAlign(TextAlign.Center)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+          Text(this.resolveNextLyricText())
+            .fontSize(13)
+            .fontColor('#D8FFFFFF')
+            .maxLines(1)
+            .textAlign(TextAlign.Center)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          Column() {
+            Slider({
+              value: this.resolveSliderValue(),
+              min: 0,
+              max: Math.max(this.durationMs, 1),
+              step: 1000,
+              style: SliderStyle.InSet
+            })
+              .width('100%')
+              .height(6)
+              .blockColor(Color.White)
+              .trackColor('#58FFFFFF')
+              .selectedColor('#FFF6EC')
+              .trackThickness(3)
+              .showSteps(false)
+              .showTips(false)
+              .enabled(this.hasSong && this.durationMs > 0)
+              .onChange((value: number, mode: SliderChangeMode) => {
+                this.handleSliderChange(value, mode)
+              })
+
+            Row() {
+              Text(this.resolveCurrentTimeText())
+                .fontSize(8)
+                .fontColor('#D8FFFFFF')
+              Text(this.durationTimeText)
+                .fontSize(8)
+                .fontColor('#D8FFFFFF')
+            }
+            .width('90%')
+            .justifyContent(FlexAlign.SpaceBetween)
+            .alignItems(VerticalAlign.Center)
+          }
+          .width('100%')
+          .height(10)
+          .justifyContent(FlexAlign.Center)
+
+          Row() {
+            this.ControlButton($r('sys.symbol.backward_end_fill'),
+              MusicCardActionConstants.ACTION_PREVIOUS, 30, 28)
+
+            Button({ type: ButtonType.Circle, stateEffect: true }) {
+              SymbolGlyph(this.isPlaying ? $r('sys.symbol.pause_fill') : $r('sys.symbol.play_fill'))
+                .fontSize(33)
+                .fontColor([Color.White])
+                .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+            }
+            .width(36)
+            .height(36)
+            .backgroundColor(Color.Transparent)
+            .onClick(() => this.postControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE))
+
+            this.ControlButton($r('sys.symbol.forward_end_fill'),
+              MusicCardActionConstants.ACTION_NEXT, 30, 28)
+          }
+          .width('100%')
+          .justifyContent(FlexAlign.SpaceBetween)
+          .alignItems(VerticalAlign.Top)
+          .margin({bottom:10})
+        }
+        .width('100%')
+        .justifyContent(FlexAlign.Center)
+        .alignItems(HorizontalAlign.Center)
+        .layoutWeight(1)
+      }
+      .width('100%')
+      .height('100%')
+      .padding({ left: 18, right: 18, top: 18, bottom: 18 })
+    }
+    .width('100%')
+    .height('100%')
+    .clip(true)
+    .onClick(() => this.postRouterAction(MusicCardActionConstants.ACTION_OPEN_PLAYER))
+  }
+}

+ 284 - 0
entry/src/main/ets/widget/pages/MusicPlayerWidgetWideCard.ets

@@ -0,0 +1,284 @@
+import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
+import Logger from '../../common/util/Logger'
+import {
+  clampMusicCardWidgetProgress,
+  formatMusicCardWidgetTime,
+  resolveMusicCardWidgetCoverSource
+} from '../common/MusicPlayerWidgetHelper'
+
+const wideCardStorage: LocalStorage = new LocalStorage()
+const ENTRY_ABILITY_NAME = 'EntryAbility'
+const SLIDER_CHANGE_MODE_END = 2
+const TAG = 'MusicPlayerWidgetWideCard'
+
+@Entry(wideCardStorage)
+@Component
+struct MusicPlayerWidgetWideCard {
+  @LocalStorageProp('formId') formId: string = ''
+  @LocalStorageProp('title') title: string = '未在播放'
+  @LocalStorageProp('artist') artist: string = '点击打开播放器'
+  @LocalStorageProp('coverPath') coverPath: string = ''
+  @LocalStorageProp('coverImageName') coverImageName: string = ''
+  @LocalStorageProp('hasCoverImage') hasCoverImage: boolean = false
+  @LocalStorageProp('hasSong') hasSong: boolean = false
+  @LocalStorageProp('isPlaying') isPlaying: boolean = false
+  @LocalStorageProp('currentPositionMs') @Watch('syncSliderFromPlayback') currentPositionMs: number = 0
+  @LocalStorageProp('durationMs') @Watch('syncSliderFromPlayback') durationMs: number = 0
+  @LocalStorageProp('currentTimeText') currentTimeText: string = '00:00'
+  @LocalStorageProp('durationTimeText') durationTimeText: string = '00:00'
+
+  @State isDragging: boolean = false
+  @State dragProgressMs: number = 0
+  @State sliderProgressMs: number = 0
+
+  aboutToAppear(): void {
+    Logger.info(TAG,
+      `musicCard wide aboutToAppear formId=${this.formId}, title=${this.title}, artist=${this.artist}, ` +
+      `coverImageName=${this.coverImageName}, coverPath=${this.coverPath}, hasCoverImage=${this.hasCoverImage}, ` +
+      `hasSong=${this.hasSong}, isPlaying=${this.isPlaying}, currentPositionMs=${this.currentPositionMs}, ` +
+      `durationMs=${this.durationMs}`)
+    this.syncSliderFromPlayback()
+  }
+
+  private syncSliderFromPlayback(): void {
+    if (this.isDragging) {
+      return
+    }
+    this.sliderProgressMs = clampMusicCardWidgetProgress(this.currentPositionMs, this.durationMs)
+  }
+
+  private postControlAction(action: string): void {
+    Logger.info(TAG, `musicCard wide postControlAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'call',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        method: MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION,
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private postSeekAction(positionMs: number): void {
+    Logger.info(TAG, `musicCard wide postSeekAction formId=${this.formId}, positionMs=${positionMs}`)
+    postCardAction(this, {
+      action: 'call',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        method: MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION,
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: MusicCardActionConstants.ACTION_SEEK_TO,
+        ttmusic_music_card_seek_position_ms: `${Math.max(0, Math.floor(positionMs))}`,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private postRouterAction(action: string): void {
+    Logger.info(TAG, `musicCard wide postRouterAction formId=${this.formId}, action=${action}`)
+    postCardAction(this, {
+      action: 'router',
+      abilityName: ENTRY_ABILITY_NAME,
+      params: {
+        ttmusic_music_card_form_id: this.formId,
+        ttmusic_music_card_action: action,
+        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
+      }
+    })
+  }
+
+  private resolveCoverSource(): string | Resource {
+    return resolveMusicCardWidgetCoverSource(this.coverImageName, this.coverPath, this.hasCoverImage)
+  }
+
+  private resolveSliderValue(): number {
+    if (this.isDragging) {
+      return this.dragProgressMs
+    }
+    return this.sliderProgressMs
+  }
+
+  private resolveCurrentTimeText(): string {
+    if (this.isDragging) {
+      return formatMusicCardWidgetTime(this.dragProgressMs)
+    }
+    return this.currentTimeText
+  }
+
+  private handleSliderChange(value: number, mode: SliderChangeMode): void {
+    const nextValue = clampMusicCardWidgetProgress(value, this.durationMs)
+    if (mode !== SLIDER_CHANGE_MODE_END) {
+      Logger.info(TAG, `musicCard wide slider dragging formId=${this.formId}, value=${nextValue}, mode=${mode}`)
+      this.isDragging = true
+      this.dragProgressMs = nextValue
+      return
+    }
+    Logger.info(TAG, `musicCard wide slider end formId=${this.formId}, value=${nextValue}, mode=${mode}`)
+    this.dragProgressMs = nextValue
+    this.sliderProgressMs = nextValue
+    this.isDragging = false
+    this.postSeekAction(nextValue)
+  }
+
+  @Builder
+  private ControlButton(imageResource: Resource, action: string, buttonSize: number, symbolSize: number) {
+    Button({ type: ButtonType.Circle, stateEffect: true }) {
+      SymbolGlyph(imageResource)
+        .fontSize(symbolSize)
+        .fontColor([Color.White])
+        .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+    }
+    .width(buttonSize * 1.35)
+    .height(buttonSize * 1.35)
+    .backgroundColor(Color.Transparent)
+    .onClick(() => this.postControlAction(action))
+  }
+
+  private hasCoverBackground(): boolean {
+    if (this.hasCoverImage && this.coverImageName.length > 0) {
+      return true;
+    }
+    return this.coverPath.length > 0;
+  }
+
+  private resolveTitleArtistText(): string {
+    const safeTitle = this.title?.trim() ?? ''
+    const safeArtist = this.artist?.trim() ?? ''
+    if (safeTitle !== '' && safeArtist !== '') {
+      return `${safeTitle} - ${safeArtist}`
+    }
+    if (safeTitle !== '') {
+      return safeTitle
+    }
+    if (safeArtist !== '') {
+      return safeArtist
+    }
+    return '未在播放'
+  }
+
+  build() {
+    Stack() {
+      if (this.hasCoverBackground()) {
+        Column()
+          .width('100%')
+          .height('100%')
+          .backgroundImage(this.resolveCoverSource())
+          .backgroundImageSize({ width: '100%' })
+          .backgroundBlurStyle(BlurStyle.BACKGROUND_ULTRA_THICK)
+      } else {
+        Column()
+          .width('100%')
+          .height('100%')
+          .linearGradient( { direction: GradientDirection.Right, colors:[['#00BC70',0.0],['#ff37a0fc',0.5],['#D81B60',1.0]]})
+      }
+
+      Column({ space: 16 }) {
+        Row({ space: 16 }) {
+          Image(this.resolveCoverSource())
+            .width(120)
+            .height(120)
+            .borderRadius(10)
+            .objectFit(ImageFit.Cover)
+
+          Column({ space: 12 }) {
+            Column({ space: 4 }) {
+              Text(this.title)
+                .fontSize(18)
+                .fontWeight(FontWeight.Bold)
+                .fontColor(Color.White)
+                .maxLines(2)
+                .textAlign(TextAlign.Center)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+              Text(this.artist)
+                .fontSize(15)
+                .fontWeight(FontWeight.Bold)
+                .fontColor('#E6FFFFFF')
+                .maxLines(1)
+                .margin({top:4})
+                .textAlign(TextAlign.Center)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+            }
+            .width('100%')
+            .alignItems(HorizontalAlign.Center)
+
+            Column() {
+              Slider({
+                value: this.resolveSliderValue(),
+                min: 0,
+                max: Math.max(this.durationMs, 1),
+                step: 1000,
+                style: SliderStyle.InSet
+              })
+                .width('100%')
+                .height(6)
+                .blockColor(Color.White)
+                .trackColor('#50FFFFFF')
+                .selectedColor('#FFF6E8')
+                .trackThickness(3)
+                .showSteps(false)
+                .showTips(false)
+                .enabled(this.hasSong && this.durationMs > 0)
+                .onChange((value: number, mode: SliderChangeMode) => {
+                  this.handleSliderChange(value, mode)
+                })
+
+              Row() {
+                Text(this.resolveCurrentTimeText())
+                  .fontSize(9)
+                  .fontColor('#CFFBFFFF')
+                Text(this.durationTimeText)
+                  .fontSize(9)
+                  .fontColor('#CFFBFFFF')
+              }
+              .width('90%')
+              .justifyContent(FlexAlign.SpaceBetween)
+              .alignItems(VerticalAlign.Center)
+            }
+            .width('100%')
+            .height(8)
+            .justifyContent(FlexAlign.Center)
+            .padding({top:5,left:2,right:2})
+
+            Row({space:2}) {
+              this.ControlButton($r('sys.symbol.backward_end_fill'),
+                MusicCardActionConstants.ACTION_PREVIOUS, 30, 28)
+
+              Button({ type: ButtonType.Circle, stateEffect: true }) {
+                SymbolGlyph(this.isPlaying ? $r('sys.symbol.pause_fill') : $r('sys.symbol.play_fill'))
+                  .fontSize(34)
+                  .fontColor([Color.White])
+                  .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+              }
+              .width(36)
+              .height(36)
+              .backgroundColor(Color.Transparent)
+              .onClick(() => this.postControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE))
+
+              this.ControlButton($r('sys.symbol.forward_end_fill'),
+                MusicCardActionConstants.ACTION_NEXT, 30, 28)
+            }
+            .justifyContent(FlexAlign.SpaceBetween)
+            .width('100%')
+            .alignItems(VerticalAlign.Center)
+          }
+          .width('100%')
+          .justifyContent(FlexAlign.Center)
+          .alignItems(HorizontalAlign.Center)
+          .layoutWeight(1)
+
+        }
+
+
+      }
+      .width('100%')
+      .height('100%')
+      .padding({ left: 18, right: 18, top: 18, bottom: 18 })
+    }
+    .width('100%')
+    .height('100%')
+    .clip(true)
+    .onClick(() => this.postRouterAction(MusicCardActionConstants.ACTION_OPEN_PLAYER))
+  }
+}

+ 18 - 1
entry/src/main/module.json5

@@ -93,6 +93,23 @@
     ],
 
 
+    "extensionAbilities": [
+      {
+        "name": "MusicCardFormAbility",
+        "srcEntry": "./ets/entryformability/MusicCardFormAbility.ets",
+        "label": "$string:EntryAbility_label",
+        "description": "$string:EntryAbility_desc",
+        "type": "form",
+        "metadata": [
+          {
+            "name": "ohos.extension.form",
+            "resource": "$profile:form_config"
+          }
+        ]
+      }
+    ],
+
+
     "requestPermissions": [
       {
         "name": "ohos.permission.INTERNET",
@@ -137,4 +154,4 @@
     ],
 
   }
-}
+}

+ 25 - 1
entry/src/main/resources/base/element/string.json

@@ -730,6 +730,30 @@
     {
       "name": "dir",
       "value": "文件夹"
+    },
+    {
+      "name": "music_card_small_display_name",
+      "value": "音乐播放卡片"
+    },
+    {
+      "name": "music_card_small_desc",
+      "value": "音乐播放卡片(普通模式)"
+    },
+    {
+      "name": "music_card_wide_display_name",
+      "value": "音乐播放卡片(中等)"
+    },
+    {
+      "name": "music_card_wide_desc",
+      "value": "音乐播放卡片(中等模式)"
+    },
+    {
+      "name": "music_card_lyric_display_name",
+      "value": "音乐歌词卡片"
+    },
+    {
+      "name": "music_card_lyric_desc",
+      "value": "音乐歌词卡片(两行歌词)"
     }
   ]
-}
+}

+ 64 - 0
entry/src/main/resources/base/profile/form_config.json

@@ -0,0 +1,64 @@
+{
+  "forms": [
+    {
+      "name": "music_player_widget",
+      "displayName": "$string:music_card_small_display_name",
+      "description": "$string:music_card_small_desc",
+      "src": "./ets/widget/pages/MusicPlayerWidgetCard.ets",
+      "uiSyntax": "arkts",
+      "window": {
+        "designWidth": 720,
+        "autoDesignWidth": true
+      },
+      "colorMode": "auto",
+      "isDynamic": true,
+      "isDefault": true,
+      "updateEnabled": false,
+      "updateDuration": 1,
+      "defaultDimension": "2*2",
+      "supportDimensions": [
+        "2*2"
+      ]
+    },
+    {
+      "name": "music_player_widget_wide",
+      "displayName": "$string:music_card_wide_display_name",
+      "description": "$string:music_card_wide_desc",
+      "src": "./ets/widget/pages/MusicPlayerWidgetWideCard.ets",
+      "uiSyntax": "arkts",
+      "window": {
+        "designWidth": 720,
+        "autoDesignWidth": true
+      },
+      "colorMode": "auto",
+      "isDynamic": true,
+      "isDefault": false,
+      "updateEnabled": false,
+      "updateDuration": 1,
+      "defaultDimension": "2*4",
+      "supportDimensions": [
+        "2*4"
+      ]
+    },
+    {
+      "name": "music_player_widget_lyric",
+      "displayName": "$string:music_card_lyric_display_name",
+      "description": "$string:music_card_lyric_desc",
+      "src": "./ets/widget/pages/MusicPlayerWidgetLyricCard.ets",
+      "uiSyntax": "arkts",
+      "window": {
+        "designWidth": 720,
+        "autoDesignWidth": true
+      },
+      "colorMode": "auto",
+      "isDynamic": true,
+      "isDefault": false,
+      "updateEnabled": false,
+      "updateDuration": 1,
+      "defaultDimension": "2*4",
+      "supportDimensions": [
+        "2*4"
+      ]
+    }
+  ]
+}

+ 10 - 0
entry/src/ohosTest/ets/test/List.test.ets

@@ -14,13 +14,23 @@
  */
 
 import abilityTest from './Ability.test'
+import findPlaylistStoreTest from './FindPlaylistStore.test'
+import localMusicMenuScrollHelperTest from './LocalMusicMenuScrollHelper.test'
 import musicPlaybackControllerTest from './MusicPlaybackController.test'
 import playbackCoordinatorTest from './PlaybackCoordinator.test'
 import playbackStateBridgeTest from './PlaybackStateBridge.test'
+import remoteMusicViewModeHelperTest from './RemoteMusicViewModeHelper.test'
+import musicCardSnapshotTest from './MusicCardSnapshot.test'
+import musicCardManagerTest from './MusicCardManager.test'
 
 export default function testsuite() {
   abilityTest()
+  findPlaylistStoreTest()
+  localMusicMenuScrollHelperTest()
   musicPlaybackControllerTest()
   playbackCoordinatorTest()
   playbackStateBridgeTest()
+  remoteMusicViewModeHelperTest()
+  musicCardSnapshotTest()
+  musicCardManagerTest()
 }

+ 66 - 0
entry/src/ohosTest/ets/test/MusicCardManager.test.ets

@@ -0,0 +1,66 @@
+import { describe, it, expect } from '@ohos/hypium'
+import { MusicCardManager } from '../../../main/ets/common/player/MusicCardManager'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+import { createEmptyMusicCardSnapshot } from '../../../main/ets/common/player/MusicCardSnapshot'
+import { MusicCardSnapshotStore } from '../../../main/ets/common/player/MusicCardSnapshotStore'
+
+export default function musicCardManagerTest() {
+  describe('MusicCardManagerTest', () => {
+    it('buildSnapshotForTestDerivesSnapshotFields', 0, () => {
+      const song = new VideoItem('夜曲', '1', '/storage/track.mp3', 0, 0, '')
+      song.artist = '周杰伦'
+      song.lyricContent = '[00:00.00]第一句\n[00:03.00]第二句\n[00:05.00]周杰伦'
+
+      const snapshot = MusicCardManager.buildSnapshotForTest(
+        song,
+        true,
+        3500,
+        180000,
+        '/cache/cover.jpg'
+      )
+
+      expect(snapshot.title).assertEqual('夜曲')
+      expect(snapshot.artist).assertEqual('周杰伦')
+      expect(snapshot.lyricLine1).assertEqual('第二句')
+      expect(snapshot.lyricLine2).assertEqual('周杰伦')
+      expect(snapshot.hasSong).assertEqual(true)
+      expect(snapshot.isPlaying).assertEqual(true)
+    })
+
+    it('shouldUpdateLyricCardReturnsFalseWhenWithinThreshold', 0, () => {
+      const result = MusicCardManager.shouldUpdateLyricCardForTest(1000, 1500, 800)
+      expect(result).assertEqual(false)
+    })
+
+    it('shouldUpdateLyricCardReturnsTrueWhenBeyondThreshold', 0, () => {
+      const result = MusicCardManager.shouldUpdateLyricCardForTest(1000, 2100, 800)
+      expect(result).assertEqual(true)
+    })
+
+    it('notifyPlaybackStateChangedSkipsUpdateWhenWriteFails', 0, () => {
+      class TestMusicCardManager extends MusicCardManager {
+        updateCalled: boolean = false
+
+        updateAllForms(): void {
+          this.updateCalled = true
+        }
+      }
+
+      const manager = new TestMusicCardManager()
+      const snapshot = createEmptyMusicCardSnapshot()
+      const store = MusicCardSnapshotStore as unknown as {
+        writeSnapshot: (value: unknown, context?: unknown) => boolean
+      }
+      const originalWrite = store.writeSnapshot
+
+      store.writeSnapshot = () => false
+      try {
+        manager.notifyPlaybackStateChanged(undefined, snapshot)
+      } finally {
+        store.writeSnapshot = originalWrite
+      }
+
+      expect(manager.updateCalled).assertEqual(false)
+    })
+  })
+}

+ 140 - 0
entry/src/ohosTest/ets/test/MusicCardSnapshot.test.ets

@@ -0,0 +1,140 @@
+import { describe, it, expect } from '@ohos/hypium'
+import {
+  buildMusicCardBindingData,
+  createEmptyMusicCardSnapshot,
+  resolveMusicCardLyricLines
+} from '../../../main/ets/common/player/MusicCardSnapshot'
+
+export default function musicCardSnapshotTest() {
+  describe('MusicCardSnapshotTest', () => {
+    it('resolveMusicCardLyricLinesReturnsCurrentAndNextLine', 0, () => {
+      const lyricText = '[00:00.00]第一句\n[00:05.00]第二句\n[00:10.00]第三句'
+      const lines = resolveMusicCardLyricLines(lyricText, 6000)
+
+      expect(lines.line1).assertEqual('第二句')
+      expect(lines.line2).assertEqual('第三句')
+      expect(lines.hasLyric).assertEqual(true)
+    })
+
+    it('resolveMusicCardLyricLinesSupportsThreeDigitMs', 0, () => {
+      const lines = resolveMusicCardLyricLines('[00:05.123]三位', 6000)
+
+      expect(lines.line1).assertEqual('三位')
+      expect(lines.line2).assertEqual('')
+      expect(lines.hasLyric).assertEqual(true)
+    })
+
+    it('resolveMusicCardLyricLinesHandlesMultipleTimestamps', 0, () => {
+      const lines = resolveMusicCardLyricLines('[00:00.00][00:05.00]第二句', 6000)
+
+      expect(lines.line1).assertEqual('第二句')
+      expect(lines.line2).assertEqual('')
+      expect(lines.hasLyric).assertEqual(true)
+    })
+
+    it('resolveMusicCardLyricLinesIgnoresEmptyText', 0, () => {
+      const lines = resolveMusicCardLyricLines('[00:12.000]   ', 12000)
+
+      expect(lines.line1).assertEqual('')
+      expect(lines.line2).assertEqual('')
+      expect(lines.hasLyric).assertEqual(false)
+    })
+
+    it('resolveMusicCardLyricLinesReturnsEmptyForBlankInput', 0, () => {
+      const lines = resolveMusicCardLyricLines('', 0)
+
+      expect(lines.line1).assertEqual('')
+      expect(lines.line2).assertEqual('')
+      expect(lines.hasLyric).assertEqual(false)
+    })
+
+    it('resolveMusicCardLyricLinesHandlesNoMilliseconds', 0, () => {
+      const lines = resolveMusicCardLyricLines('[00:05]无毫秒', 6000)
+
+      expect(lines.line1).assertEqual('无毫秒')
+      expect(lines.line2).assertEqual('')
+      expect(lines.hasLyric).assertEqual(true)
+    })
+
+    it('buildMusicCardBindingDataForEmptySnapshot', 0, () => {
+      const binding = buildMusicCardBindingData(createEmptyMusicCardSnapshot())
+
+      expect(binding.title).assertEqual('未在播放')
+      expect(binding.artist).assertEqual('点击打开播放器')
+      expect(binding.hasSong).assertEqual(false)
+      expect(binding.lyricLine1).assertEqual('未在播放')
+      expect(binding.lyricLine2).assertEqual('点击打开播放器')
+    })
+
+    it('buildMusicCardBindingDataMapsMediaFields', 0, () => {
+      const snapshot = createEmptyMusicCardSnapshot()
+      snapshot.hasSong = true
+      snapshot.title = 'Song Title'
+      snapshot.artist = 'Song Artist'
+      snapshot.coverPath = '/cover.png'
+      snapshot.hasCoverImage = true
+      snapshot.isPlaying = true
+      snapshot.currentTimeText = '00:12'
+      snapshot.durationTimeText = '03:45'
+      snapshot.filePath = '/storage/song.mp3'
+      snapshot.updatedAtMs = 123456
+
+      const binding = buildMusicCardBindingData(snapshot)
+
+      expect(binding.coverPath).assertEqual('/cover.png')
+      expect(binding.hasCoverImage).assertEqual(true)
+      expect(binding.isPlaying).assertEqual(true)
+      expect(binding.currentTimeText).assertEqual('00:12')
+      expect(binding.durationTimeText).assertEqual('03:45')
+      expect(binding.filePath).assertEqual('/storage/song.mp3')
+      expect(binding.updatedAtMs).assertEqual(123456)
+      expect(binding.hasSong).assertEqual(true)
+      expect(binding.title).assertEqual('Song Title')
+      expect(binding.artist).assertEqual('Song Artist')
+    })
+
+    it('buildMusicCardBindingDataNormalizesEmptyStateWhenNoSong', 0, () => {
+      const snapshot = createEmptyMusicCardSnapshot()
+      snapshot.coverPath = '/cover.png'
+      snapshot.hasCoverImage = true
+      snapshot.currentPositionMs = 12345
+      snapshot.durationMs = 67890
+      snapshot.currentTimeText = '00:12'
+      snapshot.durationTimeText = '05:01'
+      snapshot.filePath = '/storage/track.mp3'
+      snapshot.isPlaying = true
+      snapshot.lyricText = '[00:00.00]残留'
+      snapshot.hasLyric = true
+      snapshot.lyricLine1 = '残留'
+      snapshot.lyricLine2 = ''
+
+      const binding = buildMusicCardBindingData(snapshot)
+
+      expect(binding.hasSong).assertEqual(false)
+      expect(binding.coverPath).assertEqual('')
+      expect(binding.hasCoverImage).assertEqual(false)
+      expect(binding.isPlaying).assertEqual(false)
+      expect(binding.currentPositionMs).assertEqual(0)
+      expect(binding.durationMs).assertEqual(0)
+      expect(binding.currentTimeText).assertEqual('00:00')
+      expect(binding.durationTimeText).assertEqual('00:00')
+      expect(binding.filePath).assertEqual('')
+      expect(binding.hasLyric).assertEqual(false)
+      expect(binding.lyricLine1).assertEqual('未在播放')
+      expect(binding.lyricLine2).assertEqual('点击打开播放器')
+    })
+
+    it('buildMusicCardBindingDataDerivesLyricLinesWhenSongPlaying', 0, () => {
+      const snapshot = createEmptyMusicCardSnapshot()
+      snapshot.hasSong = true
+      snapshot.lyricText = '[00:00.00]甲\n[00:05.00]乙'
+      snapshot.currentPositionMs = 6000
+
+      const binding = buildMusicCardBindingData(snapshot)
+
+      expect(binding.hasLyric).assertEqual(true)
+      expect(binding.lyricLine1).assertEqual('乙')
+      expect(binding.lyricLine2).assertEqual('')
+    })
+  })
+}

+ 18 - 0
entry/src/ohosTest/ets/test/PlaybackCoordinator.test.ets

@@ -130,6 +130,24 @@ export default function playbackCoordinatorTest() {
       expect(playOrPauseCount).assertEqual(1)
     })
 
+    it('hasRuntimeReflectsRegistrationState', 0, () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      coordinator.clearRuntime()
+      expect(coordinator.hasRuntime()).assertFalse()
+
+      coordinator.setRuntime({
+        playQueue: async () => {},
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      expect(coordinator.hasRuntime()).assertTrue()
+      coordinator.clearRuntime()
+      expect(coordinator.hasRuntime()).assertFalse()
+    })
+
     it('coordinatorTransportControlsUseRegisteredRuntimeOnly', 0, async () => {
       const coordinator = PlaybackCoordinator.getInstance()
       const calls: string[] = []