Sfoglia il codice sorgente

实现音乐卡片功能

onecold 4 mesi fa
parent
commit
bc748a7170

+ 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'
+}

+ 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 = ''
+  }
+}

+ 40 - 18
entry/src/main/ets/common/player/MusicCardFormStore.ets

@@ -5,11 +5,11 @@ import Logger from '../util/Logger'
 const TAG = 'MusicCardFormStore'
 const TAG = 'MusicCardFormStore'
 const STORE_NAME = 'music_card_form_store'
 const STORE_NAME = 'music_card_form_store'
 const FORM_IDS_KEY = 'music_card_form_ids'
 const FORM_IDS_KEY = 'music_card_form_ids'
-type Preferences = ReturnType<typeof data_preferences.getPreferencesSync>
 
 
-const formatError = (error: unknown): string => {
-  if (error instanceof Error) {
-    return error.message
+const formatError = (error: Object): string => {
+  const err = error as Error
+  if (err && err.message) {
+    return err.message
   }
   }
   return `${error}`
   return `${error}`
 }
 }
@@ -21,21 +21,23 @@ const resolveContext = (context?: common.Context): common.Context | undefined =>
   return AppStorage.get('context') as common.Context | undefined
   return AppStorage.get('context') as common.Context | undefined
 }
 }
 
 
-const resolvePreferences = (context?: common.Context): Preferences | undefined => {
+const resolvePreferences = (context?: common.Context): data_preferences.Preferences | undefined => {
   const resolvedContext = resolveContext(context)
   const resolvedContext = resolveContext(context)
   if (!resolvedContext) {
   if (!resolvedContext) {
     Logger.warn(TAG, 'context is undefined')
     Logger.warn(TAG, 'context is undefined')
     return undefined
     return undefined
   }
   }
   try {
   try {
-    return data_preferences.getPreferencesSync(resolvedContext, STORE_NAME)
+    return data_preferences.getPreferencesSync(resolvedContext, {
+      name: STORE_NAME
+    })
   } catch (error) {
   } catch (error) {
-    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error)}`)
+    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error as Object)}`)
     return undefined
     return undefined
   }
   }
 }
 }
 
 
-const normalizeFormId = (value: unknown): string | null => {
+const normalizeFormId = (value: string | number | boolean | Object | undefined): string | null => {
   if (typeof value === 'string') {
   if (typeof value === 'string') {
     const normalized = value.trim()
     const normalized = value.trim()
     return normalized.length > 0 ? normalized : null
     return normalized.length > 0 ? normalized : null
@@ -46,12 +48,15 @@ const normalizeFormId = (value: unknown): string | null => {
   return null
   return null
 }
 }
 
 
-const readFormIdsFromPreferences = (preferences: Preferences): string[] => {
+const readFormIdsFromPreferences = (preferences: data_preferences.Preferences): string[] => {
   let raw = ''
   let raw = ''
   try {
   try {
-    raw = preferences.getSync(FORM_IDS_KEY, '') as string
+    const value: data_preferences.ValueType = preferences.getSync(FORM_IDS_KEY, '')
+    if (typeof value === 'string') {
+      raw = value
+    }
   } catch (error) {
   } catch (error) {
-    Logger.error(TAG, `readFormIds failed: ${formatError(error)}`)
+    Logger.error(TAG, `readFormIds failed: ${formatError(error as Object)}`)
     return []
     return []
   }
   }
   if (!raw || raw.length === 0) {
   if (!raw || raw.length === 0) {
@@ -69,70 +74,87 @@ const readFormIdsFromPreferences = (preferences: Preferences): string[] => {
         result.push(normalized)
         result.push(normalized)
       }
       }
     }
     }
-    return Array.from(new Set(result))
+    const uniqueResult: string[] = []
+    for (let i = 0; i < result.length; i++) {
+      if (!uniqueResult.includes(result[i])) {
+        uniqueResult.push(result[i])
+      }
+    }
+    return uniqueResult
   } catch (error) {
   } catch (error) {
-    Logger.error(TAG, `parseFormIds failed: ${formatError(error)}`)
+    Logger.error(TAG, `parseFormIds failed: ${formatError(error as Object)}`)
     return []
     return []
   }
   }
 }
 }
 
 
 const writeFormIdsToPreferences = (
 const writeFormIdsToPreferences = (
-  preferences: Preferences,
+  preferences: data_preferences.Preferences,
   formIds: string[]
   formIds: string[]
 ): void => {
 ): void => {
   const payload = JSON.stringify(formIds ?? [])
   const payload = JSON.stringify(formIds ?? [])
   preferences.putSync(FORM_IDS_KEY, payload)
   preferences.putSync(FORM_IDS_KEY, payload)
-  preferences.flushSync()
+  preferences.flush()
 }
 }
 
 
 export class MusicCardFormStore {
 export class MusicCardFormStore {
   static readFormIds(context?: common.Context): string[] {
   static readFormIds(context?: common.Context): string[] {
     const preferences = resolvePreferences(context)
     const preferences = resolvePreferences(context)
     if (!preferences) {
     if (!preferences) {
+      Logger.warn(TAG, 'musicCard readFormIds skipped because preferences missing')
       return []
       return []
     }
     }
-    return readFormIdsFromPreferences(preferences)
+    const formIds = readFormIdsFromPreferences(preferences)
+    Logger.info(TAG, `musicCard readFormIds formIds=${JSON.stringify(formIds)}`)
+    return formIds
   }
   }
 
 
   static addFormId(context: common.Context | undefined, formId: string): void {
   static addFormId(context: common.Context | undefined, formId: string): void {
     const preferences = resolvePreferences(context)
     const preferences = resolvePreferences(context)
     if (!preferences) {
     if (!preferences) {
+      Logger.warn(TAG, `musicCard addFormId skipped because preferences missing formId=${formId}`)
       return
       return
     }
     }
     const normalized = normalizeFormId(formId)
     const normalized = normalizeFormId(formId)
     if (normalized === null) {
     if (normalized === null) {
+      Logger.warn(TAG, `musicCard addFormId skipped because invalid formId=${formId}`)
       return
       return
     }
     }
     const current = readFormIdsFromPreferences(preferences)
     const current = readFormIdsFromPreferences(preferences)
     if (current.includes(normalized)) {
     if (current.includes(normalized)) {
+      Logger.info(TAG, `musicCard addFormId ignored duplicate formId=${normalized}`)
       return
       return
     }
     }
     current.push(normalized)
     current.push(normalized)
     try {
     try {
       writeFormIdsToPreferences(preferences, current)
       writeFormIdsToPreferences(preferences, current)
+      Logger.info(TAG, `musicCard addFormId success formId=${normalized}, formIds=${JSON.stringify(current)}`)
     } catch (error) {
     } catch (error) {
-      Logger.error(TAG, `addFormId failed: ${formatError(error)}`)
+      Logger.error(TAG, `addFormId failed: ${formatError(error as Object)}`)
     }
     }
   }
   }
 
 
   static removeFormId(context: common.Context | undefined, formId: string): void {
   static removeFormId(context: common.Context | undefined, formId: string): void {
     const preferences = resolvePreferences(context)
     const preferences = resolvePreferences(context)
     if (!preferences) {
     if (!preferences) {
+      Logger.warn(TAG, `musicCard removeFormId skipped because preferences missing formId=${formId}`)
       return
       return
     }
     }
     const normalized = normalizeFormId(formId)
     const normalized = normalizeFormId(formId)
     if (normalized === null) {
     if (normalized === null) {
+      Logger.warn(TAG, `musicCard removeFormId skipped because invalid formId=${formId}`)
       return
       return
     }
     }
     const current = readFormIdsFromPreferences(preferences)
     const current = readFormIdsFromPreferences(preferences)
     const next = current.filter((item) => item !== normalized)
     const next = current.filter((item) => item !== normalized)
     if (next.length === current.length) {
     if (next.length === current.length) {
+      Logger.info(TAG, `musicCard removeFormId ignored missing formId=${normalized}`)
       return
       return
     }
     }
     try {
     try {
       writeFormIdsToPreferences(preferences, next)
       writeFormIdsToPreferences(preferences, next)
+      Logger.info(TAG, `musicCard removeFormId success formId=${normalized}, formIds=${JSON.stringify(next)}`)
     } catch (error) {
     } catch (error) {
-      Logger.error(TAG, `removeFormId failed: ${formatError(error)}`)
+      Logger.error(TAG, `removeFormId failed: ${formatError(error as Object)}`)
     }
     }
   }
   }
 }
 }

+ 300 - 1
entry/src/main/ets/common/player/MusicCardManager.ets

@@ -1,15 +1,26 @@
 import { common } from '@kit.AbilityKit'
 import { common } from '@kit.AbilityKit'
+import { formBindingData, formProvider } from '@kit.FormKit'
 import Logger from '../util/Logger'
 import Logger from '../util/Logger'
+import { MusicCardFormCoverResolver } from './MusicCardFormCoverResolver'
 import { VideoItem } from '../../viewmodel/VideoItem'
 import { VideoItem } from '../../viewmodel/VideoItem'
 import {
 import {
+  buildMusicCardBindingData,
   createEmptyMusicCardSnapshot,
   createEmptyMusicCardSnapshot,
+  MusicCardFormBindingData,
   MusicCardSnapshot,
   MusicCardSnapshot,
   resolveMusicCardLyricLines
   resolveMusicCardLyricLines
 } from './MusicCardSnapshot'
 } from './MusicCardSnapshot'
+import { MusicCardFormStore } from './MusicCardFormStore'
 import { MusicCardSnapshotStore } from './MusicCardSnapshotStore'
 import { MusicCardSnapshotStore } from './MusicCardSnapshotStore'
 
 
 const TAG = 'MusicCardManager'
 const TAG = 'MusicCardManager'
 const DEFAULT_TIME_TEXT = '00:00'
 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 => {
 const padTime = (value: number): string => {
   return value < 10 ? `0${value}` : `${value}`
   return value < 10 ? `0${value}` : `${value}`
@@ -29,6 +40,30 @@ const formatTime = (timeMs: number): string => {
   return `${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 {
 export class MusicCardManager {
   private static instance: MusicCardManager
   private static instance: MusicCardManager
 
 
@@ -56,6 +91,7 @@ export class MusicCardManager {
     snapshot.title = song.name || song.fileName || ''
     snapshot.title = song.name || song.fileName || ''
     snapshot.artist = song.artist ?? ''
     snapshot.artist = song.artist ?? ''
     snapshot.coverPath = coverPath ?? ''
     snapshot.coverPath = coverPath ?? ''
+    snapshot.coverImageName = ''
     snapshot.hasCoverImage = !!snapshot.coverPath
     snapshot.hasCoverImage = !!snapshot.coverPath
     snapshot.isPlaying = Boolean(isPlaying)
     snapshot.isPlaying = Boolean(isPlaying)
     snapshot.currentPositionMs = positionMs ?? 0
     snapshot.currentPositionMs = positionMs ?? 0
@@ -94,6 +130,42 @@ export class MusicCardManager {
     return Math.abs(currentPositionMs - lastUpdateMs) >= thresholdMs
     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(
   notifyPlaybackStateChanged(
     context: common.Context | undefined,
     context: common.Context | undefined,
     snapshot: MusicCardSnapshot
     snapshot: MusicCardSnapshot
@@ -102,6 +174,11 @@ export class MusicCardManager {
       Logger.warn(TAG, 'notifyPlaybackStateChanged skipped: snapshot is undefined')
       Logger.warn(TAG, 'notifyPlaybackStateChanged skipped: snapshot is undefined')
       return
       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()
     snapshot.updatedAtMs = Date.now()
     const saved = MusicCardSnapshotStore.writeSnapshot(snapshot, context)
     const saved = MusicCardSnapshotStore.writeSnapshot(snapshot, context)
     if (!saved) {
     if (!saved) {
@@ -111,5 +188,227 @@ export class MusicCardManager {
     this.updateAllForms(context)
     this.updateAllForms(context)
   }
   }
 
 
-  updateAllForms(_context?: common.Context): void {}
+  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
+  }
 }
 }

+ 47 - 8
entry/src/main/ets/common/player/MusicCardSnapshot.ets

@@ -1,4 +1,5 @@
 import { MUSIC_CARD_EMPTY_ARTIST, MUSIC_CARD_EMPTY_TITLE } from './MusicCardConstants'
 import { MUSIC_CARD_EMPTY_ARTIST, MUSIC_CARD_EMPTY_TITLE } from './MusicCardConstants'
+import LyricUtil from '../util/LyricUtil'
 
 
 const DEFAULT_TIME_TEXT = '00:00'
 const DEFAULT_TIME_TEXT = '00:00'
 const LYRIC_TIMESTAMP_PATTERN = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g
 const LYRIC_TIMESTAMP_PATTERN = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g
@@ -11,6 +12,7 @@ export interface MusicCardSnapshot {
   title: string
   title: string
   artist: string
   artist: string
   coverPath: string
   coverPath: string
+  coverImageName: string
   hasCoverImage: boolean
   hasCoverImage: boolean
   hasSong: boolean
   hasSong: boolean
   isPlaying: boolean
   isPlaying: boolean
@@ -32,10 +34,20 @@ export interface MusicCardLyricLines {
   hasLyric: boolean
   hasLyric: boolean
 }
 }
 
 
+class MusicCardLyricEntry {
+  timeMs: number = 0
+  text: string = ''
+}
+
+class MusicCardLyricTimestamp {
+  timeMs: number = 0
+}
+
 export class MusicCardBindingData {
 export class MusicCardBindingData {
   title: string = MUSIC_CARD_EMPTY_TITLE
   title: string = MUSIC_CARD_EMPTY_TITLE
   artist: string = MUSIC_CARD_EMPTY_ARTIST
   artist: string = MUSIC_CARD_EMPTY_ARTIST
   coverPath: string = ''
   coverPath: string = ''
+  coverImageName: string = ''
   hasCoverImage: boolean = false
   hasCoverImage: boolean = false
   hasSong: boolean = false
   hasSong: boolean = false
   isPlaying: boolean = false
   isPlaying: boolean = false
@@ -50,11 +62,17 @@ export class MusicCardBindingData {
   updatedAtMs: number = 0
   updatedAtMs: number = 0
 }
 }
 
 
+export class MusicCardFormBindingData extends MusicCardBindingData {
+  formId: string = ''
+  formImages: Record<string, number> = {}
+}
+
 export function createEmptyMusicCardSnapshot(): MusicCardSnapshot {
 export function createEmptyMusicCardSnapshot(): MusicCardSnapshot {
   return {
   return {
     title: '',
     title: '',
     artist: '',
     artist: '',
     coverPath: '',
     coverPath: '',
+    coverImageName: '',
     hasCoverImage: false,
     hasCoverImage: false,
     hasSong: false,
     hasSong: false,
     isPlaying: false,
     isPlaying: false,
@@ -71,11 +89,28 @@ export function createEmptyMusicCardSnapshot(): MusicCardSnapshot {
   }
   }
 }
 }
 
 
+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(
 export function resolveMusicCardLyricLines(
   lyricText: string,
   lyricText: string,
   positionMs: number
   positionMs: number
 ): MusicCardLyricLines {
 ): MusicCardLyricLines {
-  const trimmedText = lyricText ? lyricText.trim() : ''
+  const normalizedLyricText = LyricUtil.convertLyricToSimpleLrc(lyricText ?? '')
+  const trimmedText = normalizedLyricText ? normalizedLyricText.trim() : ''
   if (trimmedText === '') {
   if (trimmedText === '') {
     return {
     return {
       line1: '',
       line1: '',
@@ -84,7 +119,7 @@ export function resolveMusicCardLyricLines(
     }
     }
   }
   }
 
 
-  const entries: Array<{ timeMs: number; text: string }> = []
+  const entries: Array<MusicCardLyricEntry> = []
   const rawLines = trimmedText.split(/\r?\n/)
   const rawLines = trimmedText.split(/\r?\n/)
   for (let i = 0; i < rawLines.length; i++) {
   for (let i = 0; i < rawLines.length; i++) {
     const rawLine = rawLines[i].trim()
     const rawLine = rawLines[i].trim()
@@ -92,7 +127,7 @@ export function resolveMusicCardLyricLines(
       continue
       continue
     }
     }
 
 
-    const timestamps: Array<{ timeMs: number }> = []
+    const timestamps: Array<MusicCardLyricTimestamp> = []
     let lastMatchEnd = 0
     let lastMatchEnd = 0
     LYRIC_TIMESTAMP_PATTERN.lastIndex = 0
     LYRIC_TIMESTAMP_PATTERN.lastIndex = 0
     let match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
     let match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
@@ -107,7 +142,9 @@ export function resolveMusicCardLyricLines(
       }
       }
       const timeMs = minutes * 60 * 1000 + seconds * 1000 + msValue
       const timeMs = minutes * 60 * 1000 + seconds * 1000 + msValue
 
 
-      timestamps.push({ timeMs })
+      const timestamp = new MusicCardLyricTimestamp()
+      timestamp.timeMs = timeMs
+      timestamps.push(timestamp)
       lastMatchEnd = LYRIC_TIMESTAMP_PATTERN.lastIndex
       lastMatchEnd = LYRIC_TIMESTAMP_PATTERN.lastIndex
       match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
       match = LYRIC_TIMESTAMP_PATTERN.exec(rawLine)
     }
     }
@@ -122,10 +159,10 @@ export function resolveMusicCardLyricLines(
     }
     }
 
 
     for (let j = 0; j < timestamps.length; j++) {
     for (let j = 0; j < timestamps.length; j++) {
-      entries.push({
-        timeMs: timestamps[j].timeMs,
-        text
-      })
+      const entry = new MusicCardLyricEntry()
+      entry.timeMs = timestamps[j].timeMs
+      entry.text = text
+      entries.push(entry)
     }
     }
   }
   }
 
 
@@ -172,6 +209,7 @@ export function buildMusicCardBindingData(snapshot: MusicCardSnapshot): MusicCar
   data.artist =
   data.artist =
     data.hasSong && isNonEmpty(source.artist) ? source.artist : MUSIC_CARD_EMPTY_ARTIST
     data.hasSong && isNonEmpty(source.artist) ? source.artist : MUSIC_CARD_EMPTY_ARTIST
   data.coverPath = source.coverPath ?? ''
   data.coverPath = source.coverPath ?? ''
+  data.coverImageName = source.coverImageName ?? ''
   data.hasCoverImage = Boolean(source.hasCoverImage)
   data.hasCoverImage = Boolean(source.hasCoverImage)
   data.isPlaying = Boolean(source.isPlaying)
   data.isPlaying = Boolean(source.isPlaying)
   data.currentPositionMs = source.currentPositionMs ?? 0
   data.currentPositionMs = source.currentPositionMs ?? 0
@@ -187,6 +225,7 @@ export function buildMusicCardBindingData(snapshot: MusicCardSnapshot): MusicCar
 
 
   if (!data.hasSong) {
   if (!data.hasSong) {
     data.coverPath = ''
     data.coverPath = ''
+    data.coverImageName = ''
     data.hasCoverImage = false
     data.hasCoverImage = false
     data.isPlaying = false
     data.isPlaying = false
     data.currentPositionMs = 0
     data.currentPositionMs = 0

+ 40 - 13
entry/src/main/ets/common/player/MusicCardSnapshotStore.ets

@@ -6,11 +6,11 @@ import { createEmptyMusicCardSnapshot, MusicCardSnapshot } from './MusicCardSnap
 const TAG = 'MusicCardSnapshotStore'
 const TAG = 'MusicCardSnapshotStore'
 const STORE_NAME = 'music_card_snapshot_store'
 const STORE_NAME = 'music_card_snapshot_store'
 const SNAPSHOT_KEY = 'music_card_snapshot'
 const SNAPSHOT_KEY = 'music_card_snapshot'
-type Preferences = ReturnType<typeof data_preferences.getPreferencesSync>
 
 
-const formatError = (error: unknown): string => {
-  if (error instanceof Error) {
-    return error.message
+const formatError = (error: Object): string => {
+  const err = error as Error
+  if (err && err.message) {
+    return err.message
   }
   }
   return `${error}`
   return `${error}`
 }
 }
@@ -22,20 +22,44 @@ const resolveContext = (context?: common.Context): common.Context | undefined =>
   return AppStorage.get('context') as common.Context | undefined
   return AppStorage.get('context') as common.Context | undefined
 }
 }
 
 
-const resolvePreferences = (context?: common.Context): Preferences | undefined => {
+const resolvePreferences = (context?: common.Context): data_preferences.Preferences | undefined => {
   const resolvedContext = resolveContext(context)
   const resolvedContext = resolveContext(context)
   if (!resolvedContext) {
   if (!resolvedContext) {
     Logger.warn(TAG, 'context is undefined')
     Logger.warn(TAG, 'context is undefined')
     return undefined
     return undefined
   }
   }
   try {
   try {
-    return data_preferences.getPreferencesSync(resolvedContext, STORE_NAME)
+    return data_preferences.getPreferencesSync(resolvedContext, {
+      name: STORE_NAME
+    })
   } catch (error) {
   } catch (error) {
-    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error)}`)
+    Logger.error(TAG, `getPreferencesSync failed: ${formatError(error as Object)}`)
     return undefined
     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 {
 export class MusicCardSnapshotStore {
   static readSnapshot(context?: common.Context): MusicCardSnapshot {
   static readSnapshot(context?: common.Context): MusicCardSnapshot {
     const preferences = resolvePreferences(context)
     const preferences = resolvePreferences(context)
@@ -44,9 +68,12 @@ export class MusicCardSnapshotStore {
     }
     }
     let raw = ''
     let raw = ''
     try {
     try {
-      raw = preferences.getSync(SNAPSHOT_KEY, '') as string
+      const value: data_preferences.ValueType = preferences.getSync(SNAPSHOT_KEY, '')
+      if (typeof value === 'string') {
+        raw = value
+      }
     } catch (error) {
     } catch (error) {
-      Logger.error(TAG, `readSnapshot failed: ${formatError(error)}`)
+      Logger.error(TAG, `readSnapshot failed: ${formatError(error as Object)}`)
       return createEmptyMusicCardSnapshot()
       return createEmptyMusicCardSnapshot()
     }
     }
     if (!raw || raw.length === 0) {
     if (!raw || raw.length === 0) {
@@ -54,9 +81,9 @@ export class MusicCardSnapshotStore {
     }
     }
     try {
     try {
       const parsed = JSON.parse(raw) as MusicCardSnapshot
       const parsed = JSON.parse(raw) as MusicCardSnapshot
-      return { ...createEmptyMusicCardSnapshot(), ...parsed }
+      return mergeSnapshot(parsed)
     } catch (error) {
     } catch (error) {
-      Logger.error(TAG, `parseSnapshot failed: ${formatError(error)}`)
+      Logger.error(TAG, `parseSnapshot failed: ${formatError(error as Object)}`)
       return createEmptyMusicCardSnapshot()
       return createEmptyMusicCardSnapshot()
     }
     }
   }
   }
@@ -69,10 +96,10 @@ export class MusicCardSnapshotStore {
     try {
     try {
       const payload = JSON.stringify(snapshot ?? createEmptyMusicCardSnapshot())
       const payload = JSON.stringify(snapshot ?? createEmptyMusicCardSnapshot())
       preferences.putSync(SNAPSHOT_KEY, payload)
       preferences.putSync(SNAPSHOT_KEY, payload)
-      preferences.flushSync()
+      preferences.flush()
       return true
       return true
     } catch (error) {
     } catch (error) {
-      Logger.error(TAG, `writeSnapshot failed: ${formatError(error)}`)
+      Logger.error(TAG, `writeSnapshot failed: ${formatError(error as Object)}`)
       return false
       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 window from '@ohos.window';
 import { AbilityConstant, appRecovery, Want } from '@kit.AbilityKit';
 import { AbilityConstant, appRecovery, Want } from '@kit.AbilityKit';
 import { BusinessError, emitter } from '@kit.BasicServicesKit';
 import { BusinessError, emitter } from '@kit.BasicServicesKit';
+import { rpc } from '@kit.IPCKit';
 import { AppUtil, ArrayUtil, GlobalContext, LogUtil, StrUtil } from '@pura/harmony-utils';
 import { AppUtil, ArrayUtil, GlobalContext, LogUtil, StrUtil } from '@pura/harmony-utils';
 import { DemoConstants } from './DemoConstants';
 import { DemoConstants } from './DemoConstants';
 import { EventConstants } from '../common/constants/EventConstants';
 import { EventConstants } from '../common/constants/EventConstants';
@@ -29,6 +30,27 @@ import { url } from '@kit.ArkTS';
 import { display } from '@kit.ArkUI';
 import { display } from '@kit.ArkUI';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
 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
  * 主Ability类,继承自UIAbility
  * 负责:
  * 负责:
@@ -37,6 +59,58 @@ import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
  * - 事件分发
  * - 事件分发
  */
  */
 export default class EntryAbility extends UIAbility {
 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上下文对象,用于获取窗口信息
     // UI上下文对象,用于获取窗口信息
     private uiContext?: UIContext;
     private uiContext?: UIContext;
     // private awareness: smartMobilityCommon.SmartMobilityAwareness = smartMobilityCommon.getSmartMobilityAwareness();
     // private awareness: smartMobilityCommon.SmartMobilityAwareness = smartMobilityCommon.getSmartMobilityAwareness();
@@ -96,6 +170,9 @@ export default class EntryAbility extends UIAbility {
 
 
     async onCreate(want:Want, launchParam:AbilityConstant.LaunchParam) {
     async onCreate(want:Want, launchParam:AbilityConstant.LaunchParam) {
         AppStorage.setOrCreate('context', this.context);
         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');
         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> {
     async onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
         hilog.info(0x0000, 'testTag', `onNewWant, want=${JSON.stringify(want)}`);
         hilog.info(0x0000, 'testTag', `onNewWant, want=${JSON.stringify(want)}`);
         super.onNewWant(want, launchParam);
         super.onNewWant(want, launchParam);
+        this.handleMusicCardActionFromWant(want);
         this.loadDoWant(want)
         this.loadDoWant(want)
         await this.handleParam(want)
         await this.handleParam(want)
         this.handleWeChatCallIfNeed(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) {
     private handleWeChatCallIfNeed(want: Want) {
         WXApi.handleWant(want, WXEventHandler)
         WXApi.handleWant(want, WXEventHandler)
     }
     }
@@ -222,6 +452,8 @@ export default class EntryAbility extends UIAbility {
 
 
     onDestroy() {
     onDestroy() {
         try {
         try {
+        this.context.eventHub?.off('musicCardActionForward', this.musicCardActionForwardHandler);
+        this.unregisterMusicCardCallHandler();
         hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
         hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
             if(this.awareness){
             if(this.awareness){
                 let types: smartMobilityCommon.SmartMobilityType[] = [smartMobilityCommon.SmartMobilityType.CAR_HOP];
                 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 { PointLightButton } from './PointLight/PointLightButton';
 import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton'
 import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton'
 import { PlayConstants } from '../common/constants/PlayConstants';
 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 { effectKit } from '@kit.ArkGraphics2D';
 import { ColorConversion } from '../common/util/ColorConversion';
 import { ColorConversion } from '../common/util/ColorConversion';
 import { Lyric, LyricController, LyricParser, LyricView2 } from '@seagazer/cclyric';
 import { Lyric, LyricController, LyricParser, LyricView2 } from '@seagazer/cclyric';
@@ -265,7 +267,10 @@ export interface WorkerMetadataPayload {
 
 
 interface WorkerMessageScanDone {
 interface WorkerMessageScanDone {
   code: 101;
   code: 101;
-  data: Record<string, never>;
+  data: WorkerMessageScanDoneData;
+}
+
+interface WorkerMessageScanDoneData {
 }
 }
 
 
 interface WorkerMessageMediaList {
 interface WorkerMessageMediaList {
@@ -312,6 +317,10 @@ interface SongPlaybackPreparationResult {
   loadingShown: boolean;
   loadingShown: boolean;
 }
 }
 
 
+interface OpenLocalSpecialListEventData {
+  target?: string;
+}
+
 type LocalMusicWorkerMessage =
 type LocalMusicWorkerMessage =
   WorkerMessageScanDone
   WorkerMessageScanDone
     | WorkerMessageMediaList
     | WorkerMessageMediaList
@@ -1083,9 +1092,42 @@ export struct LocalMusic {
       this.doSwipBack()
       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() {
   aboutToAppear() {
     this.playbackCoordinator.setRuntime(this.playbackRuntime)
     this.playbackCoordinator.setRuntime(this.playbackRuntime)
+    this.consumePendingMusicCardAction()
+    this.consumePendingMusicCardOpenRequest()
 
 
     console.info('onecold aboutToAppear sdkApiVersion = '+deviceInfo.sdkApiVersion)
     console.info('onecold aboutToAppear sdkApiVersion = '+deviceInfo.sdkApiVersion)
     if(ArrayUtil.isNotEmpty(this.videoLocalList)&&this.modeType==0){
     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 }
     let eventOpenLocalSpecialList: emitter.InnerEvent = { eventId: EventConstants.EVENT_OPEN_LOCAL_SPECIAL_LIST }
     emitter.on(eventOpenLocalSpecialList, (eventData: emitter.EventData) => {
     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)
       this.openSpecialLocalList(target)
     });
     });
 
 
@@ -17537,6 +17579,24 @@ export struct LocalMusic {
     return this.mIjkMediaPlayer.getCurrentPosition()
     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 {
   private syncLyricPositionNow(): void {
     const position = this.getActivePlaybackPositionMs()
     const position = this.getActivePlaybackPositionMs()
     if (position < 0) {
     if (position < 0) {
@@ -17694,6 +17754,20 @@ export struct LocalMusic {
     } catch (error) {
     } catch (error) {
       // setProgress 频率较高,这里仅做兜底避免影响主流程
       // 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() {
   private startProgressTask() {
@@ -17782,6 +17856,7 @@ export struct LocalMusic {
     }
     }
     this.name = song.name
     this.name = song.name
     this.artist = song.artist
     this.artist = song.artist
+    this.notifyPlaybackCardStateChanged()
   }
   }
 
 
   private applySelectedQueueSong(index: number): void {
   private applySelectedQueueSong(index: number): void {
@@ -18106,9 +18181,9 @@ export struct LocalMusic {
         const webdavManager = RemoteDriveManager.getInstance();
         const webdavManager = RemoteDriveManager.getInstance();
         const baiduHeaders = await webdavManager.buildHttpHeadersWithAccountId(this.currentSong, this.videoUrl);
         const baiduHeaders = await webdavManager.buildHttpHeadersWithAccountId(this.currentSong, this.videoUrl);
         baiduHeaders.forEach((value, key) => headers.set(key, value));
         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) {
       } catch (error) {
         Logger.error(`heanup 构建百度请求头失败: ${(error as Error).message}`);
         Logger.error(`heanup 构建百度请求头失败: ${(error as Error).message}`);
       }
       }
@@ -18974,6 +19049,7 @@ export struct LocalMusic {
     } catch (error) {
     } catch (error) {
       Logger.warn(TAG, `回写 PlaybackStateBridge 失败: ${error}`)
       Logger.warn(TAG, `回写 PlaybackStateBridge 失败: ${error}`)
     }
     }
+    this.notifyPlaybackCardStateChanged()
   }
   }
 
 
   private setPlaybackStateChangeListener(): void {
   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
   @Require@Prop@Watch('setButtonSize') builderWidth: number
   // 可选
   // 可选
   public buttonScale: number = 1.3
   public buttonScale: number = 1.3
+  public symbolSize: number = 20
+  public imageSize: number = 20
   @State pointLightHeight: number = 100
   @State pointLightHeight: number = 100
   public canShadow: boolean = true
   public canShadow: boolean = true
   public canPointLight: boolean = true
   public canPointLight: boolean = true
@@ -87,13 +89,13 @@ export struct PointLightDefaultButton{
   imageBuilder(){
   imageBuilder(){
     if(this.isSysBol){
     if(this.isSysBol){
       SymbolGlyph(this.imageResource as Resource)
       SymbolGlyph(this.imageResource as Resource)
-        .fontSize(20)
+        .fontSize(this.symbolSize)
         .fontColor([this.pointColor])
         .fontColor([this.pointColor])
         .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
         .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
         .alignSelf(ItemAlign.Center)
         .alignSelf(ItemAlign.Center)
     }else{
     }else{
       Image(this.imageResource)
       Image(this.imageResource)
-        .width(20)
+        .width(this.imageSize)
         .fillColor(this.pointColor)
         .fillColor(this.pointColor)
         .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.5})
         .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.5})
         .aspectRatio(1)
         .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": [
     "requestPermissions": [
       {
       {
         "name": "ohos.permission.INTERNET",
         "name": "ohos.permission.INTERNET",
@@ -137,4 +154,4 @@
     ],
     ],
 
 
   }
   }
-}
+}

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

@@ -730,6 +730,30 @@
     {
     {
       "name": "dir",
       "name": "dir",
       "value": "文件夹"
       "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"
+      ]
+    }
+  ]
+}

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

@@ -130,6 +130,24 @@ export default function playbackCoordinatorTest() {
       expect(playOrPauseCount).assertEqual(1)
       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 () => {
     it('coordinatorTransportControlsUseRegisteredRuntimeOnly', 0, async () => {
       const coordinator = PlaybackCoordinator.getInstance()
       const coordinator = PlaybackCoordinator.getInstance()
       const calls: string[] = []
       const calls: string[] = []