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