import { common, Context, wantAgent, Want } from '@kit.AbilityKit' import { BusinessError } from '@kit.BasicServicesKit' import { avSession } from '@kit.AVSessionKit' import { image } from '@kit.ImageKit' import { FileUtil, ImageUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils' import { DeviceChangeReason, InterruptEvent, InterruptHintType, IjkMediaPlayer } from '@ohos/ijkplayer' import { ImplOnCompletionListener, ImplOnErrorListener, ImplOnPreparedListener, ImplOnSeekCompleteListener } from '../common/IjkPlayerListenerImpls' import { PlayStatus } from '../common/PlayStatus' import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants' import { isPlaybackControlPlaying } from '../common/player/PlaybackControlStateHelper' import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager' import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager' import Logger from '../common/util/Logger' import { imagePathToPixelMap } from '../common/util/CommUtils' import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil' import { MusicPlaybackController } from '../controller/MusicPlaybackController' import { PlaybackRuntime } from '../controller/PlaybackCoordinator' import { VideoItem } from '../viewmodel/VideoItem' import { PlaybackSnapshotStore } from './PlaybackSnapshotStore' import { BackgroundAudioControlDecision, BackgroundAudioControlKind, BackgroundAudioPersistedState, BackgroundAudioPlaybackHostHelper, BackgroundAudioRecoveredQueue } from './BackgroundAudioPlaybackHostHelper' import { PlaybackSnapshot, PlaybackSnapshotSong, resolvePlaybackSnapshotProgressValue } from './model/PlaybackSnapshot' const TAG = 'BackgroundAudioHost' const PLAYER_ID = 'audioIjkId' const PROGRESS_INTERVAL_MS = 1000 const PLAYBACK_VERIFY_DELAY_MS = 200 const PLAYBACK_VERIFY_RETRY_DELAY_MS = 350 interface PlaybackSnapshotWriter { write(snapshot: PlaybackSnapshot): void } export class BackgroundAudioPlaybackHost { private static instance: BackgroundAudioPlaybackHost private context: common.Context | undefined = undefined private player: IjkMediaPlayer | undefined = undefined private queue: VideoItem[] = [] private currentIndex: number = -1 private currentSong: VideoItem | undefined = undefined private playType: number = 0 private isPlaying: boolean = false private isPrepared: boolean = false private currentUrl: string = '' private progressTimerId: number = -1 private playbackStartVerifyToken: number = 0 private playbackSession: avSession.AVSession | undefined = undefined private creatingPlaybackSession: boolean = false private playbackSessionCallbacksRegistered: boolean = false private snapshotStore: PlaybackSnapshotWriter = new PlaybackSnapshotStore() private readonly runtime: PlaybackRuntime = { playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise => { await this.playQueue(queue, startIndex, source, playType) }, playOrPause: async (): Promise => { await this.playOrPause() }, playNext: async (): Promise => { await this.playNext() }, playPrevious: async (): Promise => { await this.playPrevious() }, setLoopMode: async (): Promise => { await this.setLoopMode() }, seekTo: async (value: string): Promise => { await this.seekTo(value) } } public static getInstance(): BackgroundAudioPlaybackHost { if (!BackgroundAudioPlaybackHost.instance) { BackgroundAudioPlaybackHost.instance = new BackgroundAudioPlaybackHost() } return BackgroundAudioPlaybackHost.instance } public setContext(context: common.Context | undefined): void { this.context = context Logger.info(TAG, `[MusicCast] setContext contextReady=${context !== undefined}`) } public getRuntime(): PlaybackRuntime { return this.runtime } public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise { if (queue.length <= 0) { Logger.warn(TAG, `[MusicCast] playQueue ignored empty queue source=${source}`) return } const safeIndex = Math.max(0, Math.min(startIndex, queue.length - 1)) this.queue = [...queue] this.currentIndex = safeIndex this.currentSong = this.queue[safeIndex] if (playType !== undefined) { this.playType = playType } Logger.info(TAG, `[MusicCast] playQueue source=${source}, queueLength=${this.queue.length}, startIndex=${startIndex}, ` + `safeIndex=${safeIndex}, playType=${this.playType}`) this.persistCurrentQueue() await this.playIndex(safeIndex) } public async playOrPause(): Promise { this.restorePersistedQueueIfNeeded() await this.handleControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE) } public async playNext(): Promise { this.restorePersistedQueueIfNeeded() await this.handleControlAction(MusicCardActionConstants.ACTION_NEXT) } public async playPrevious(): Promise { this.restorePersistedQueueIfNeeded() await this.handleControlAction(MusicCardActionConstants.ACTION_PREVIOUS) } public async seekTo(value: string, _source?: string): Promise { this.restorePersistedQueueIfNeeded() await this.handleControlAction(MusicCardActionConstants.ACTION_SEEK_TO, value) } public async setLoopMode(): Promise { this.restorePersistedQueueIfNeeded() const nextMode = MusicPlaybackController.resolveNextLoopMode(this.playType) this.playType = nextMode.playType PreferencesUtil.putSync('musicPlayType', this.playType) this.persistCurrentQueue() this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.persistPlaybackSnapshot(this.isPlaying) Logger.info(TAG, `[MusicCast] setLoopMode playType=${this.playType}, toast=${nextMode.toastText}`) } public syncCurrentSongFavoriteState(isFavorite: boolean): void { this.applyCurrentSongFavoriteState(isFavorite) } public getSpectrumData(): number[] { if (!this.player) { return [] } return this.player.getSpectrumData() } public async handleControlAction(action: string, seekPositionMs: string = ''): Promise { try { Logger.info(TAG, `[MusicCast] handleControlAction action=${action}, seek=${seekPositionMs}, queueLength=${this.queue.length}, ` + `currentIndex=${this.currentIndex}, isPlaying=${this.isPlaying}, isPrepared=${this.isPrepared}`) this.restorePersistedQueueIfNeeded() if (action === MusicCardActionConstants.ACTION_SEEK_TO) { const handled = this.seekToInternal(seekPositionMs) Logger.info(TAG, `[MusicCast] seek decision handled=${handled}, seek=${seekPositionMs}`) return handled } const decision = BackgroundAudioPlaybackHostHelper.resolveControlDecision( action, this.currentIndex, this.queue.length, this.isPlaying, this.playType ) Logger.info(TAG, `[MusicCast] decision kind=${decision.kind}, targetIndex=${decision.targetIndex}, ` + `queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, playType=${this.playType}`) return this.executeDecision(decision) } catch (error) { Logger.error(TAG, `[MusicCast] handleControlAction failed: ${(error as Error).message}`) return false } } private async executeDecision(decision: BackgroundAudioControlDecision): Promise { if (decision.kind === BackgroundAudioControlKind.PAUSE) { this.pause() return true } if (decision.kind === BackgroundAudioControlKind.STOP) { this.stop() return true } if (decision.kind === BackgroundAudioControlKind.PLAY_INDEX) { if (!this.isPlaying && this.isPrepared && decision.targetIndex === this.currentIndex) { this.resume() return true } return this.playIndex(decision.targetIndex) } return false } private restorePersistedQueueIfNeeded(): void { if (this.queue.length > 0 && this.currentIndex >= 0 && this.currentIndex < this.queue.length) { Logger.info(TAG, `[MusicCast] skip restore because queue already ready queueLength=${this.queue.length}, currentIndex=${this.currentIndex}`) return } const persistedState: BackgroundAudioPersistedState = BackgroundAudioPlaybackHostHelper.readPersistedState( () => PreferencesUtil.getSync('LastMusicList', []) as VideoItem[], () => PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem | undefined, () => PreferencesUtil.getNumberSync('LastPlayModeType', PreferencesUtil.getNumberSync('musicPlayType', 0)) ) if (persistedState.errorMessage) { Logger.error(TAG, `[MusicCast] restore persisted read failed: ${persistedState.errorMessage}`) } Logger.info(TAG, `[MusicCast] restore persisted queueLength=${persistedState.queue.length}, currentSong=${persistedState.currentSong?.name ?? ''}, ` + `path=${persistedState.currentSong?.filePath ?? ''}, playType=${persistedState.playType}`) const restored = BackgroundAudioPlaybackHostHelper.restorePersistedQueue( persistedState.queue, persistedState.currentSong, persistedState.playType ) this.applyRecoveredQueue(restored) } private applyRecoveredQueue(restored: BackgroundAudioRecoveredQueue): void { this.queue = restored.queue this.currentIndex = restored.currentIndex this.currentSong = restored.currentSong this.playType = restored.playType Logger.info(TAG, `[MusicCast] applyRecoveredQueue queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` + `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`) this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.persistPlaybackSnapshot(this.isPlaying) } private async playIndex(index: number): Promise { if (index < 0 || index >= this.queue.length) { Logger.warn(TAG, `[MusicCast] playIndex ignored invalid index=${index}, queueLength=${this.queue.length}`) return false } this.currentIndex = index this.currentSong = this.queue[index] Logger.info(TAG, `[MusicCast] playIndex index=${index}, song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, type=${this.currentSong?.type ?? -1}`) this.persistCurrentQueue() return this.prepareAndStartCurrentSong() } private async prepareAndStartCurrentSong(): Promise { if (!this.currentSong) { Logger.warn(TAG, '[MusicCast] prepare skipped because currentSong missing') return false } if (!this.context) { Logger.warn(TAG, '[MusicCast] prepare skipped because context missing') return false } try { await this.ensurePlaybackSession() const player = this.ensurePlayer() this.playbackStartVerifyToken++ this.isPrepared = false this.isPlaying = false this.currentUrl = await setVideoUrlForSong(this.currentSong, { context: this.context as Context, extractAudioInfo: false, extractCover: false, extractLyric: false }) Logger.info(TAG, `[MusicCast] prepare resolved url=${this.currentUrl}, song=${this.currentSong.name}, path=${this.currentSong.filePath}`) player.reset() player.setAudioId(PLAYER_ID) player.native_setup() player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'start-on-prepared', '1') player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'packet-buffering', '0') player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'soundtouch', '1') player.setVolume('1', '1') player.setDataSource(this.currentUrl) const headers = new Map() headers.set('User-Agent', 'TTMusic-Widget/1.0') headers.set('Accept', '*/*') player.setDataSourceHeader(headers) void this.syncPlaybackSessionMetadata() player.prepareAsync() player.start() Logger.info(TAG, `[MusicCast] prepare issued start song=${this.currentSong.name}, playerId=${PLAYER_ID}`) this.syncHostStorage(PlayStatus.PAUSE) this.publishSnapshot(false) return true } catch (error) { Logger.error(TAG, `[MusicCast] prepare failed: ${(error as Error).message}`) this.isPrepared = false this.isPlaying = false this.syncHostStorage(PlayStatus.PAUSE) this.publishSnapshot(false) return false } } private ensurePlayer(): IjkMediaPlayer { if (this.player) { return this.player } const player = new IjkMediaPlayer() player.setAudioId(PLAYER_ID) player.native_setup() player.setOnPreparedListener(new ImplOnPreparedListener(() => { this.isPrepared = true player.start() this.isPlaying = player.isPlaying() Logger.info(TAG, `[MusicCast] onPrepared song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, ` + `duration=${player.getDuration()}, isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}, ` + `audioSessionId=${player.getAudioSessionId()}`) this.startProgressTimer() this.verifyPlaybackStarted(this.playbackStartVerifyToken, 'onPrepared') this.syncHostStorage(PlayStatus.PLAY) this.syncPlaybackSessionState(PlayStatus.PLAY) this.publishSnapshot(true) })) player.setOnCompletionListener(new ImplOnCompletionListener(() => { Logger.info(TAG, `[MusicCast] onCompletion currentIndex=${this.currentIndex}, queueLength=${this.queue.length}, playType=${this.playType}`) this.handleCompletion() })) player.setOnErrorListener(new ImplOnErrorListener((what: number, extra: number) => { Logger.error(TAG, `[MusicCast] player error what=${what}, extra=${extra}, url=${this.currentUrl}`) this.isPrepared = false this.isPlaying = false this.stopProgressTimer() this.syncHostStorage(PlayStatus.PAUSE) this.syncPlaybackSessionState(PlayStatus.PAUSE) this.publishSnapshot(false) })) player.setOnSeekCompleteListener(new ImplOnSeekCompleteListener(() => { Logger.info(TAG, `[MusicCast] onSeekComplete position=${player.getCurrentPosition()}`) this.publishProgress() })) player.on('audioInterrupt', (event: InterruptEvent) => { this.handleAudioInterrupt(player, event) }) player.on('deviceChange', (event: InterruptEvent) => { Logger.info(TAG, `[MusicCast] deviceChange reason=${event.reason ?? DeviceChangeReason.REASON_UNKNOWN}`) }) player.setMessageListener() this.player = player Logger.info(TAG, `[MusicCast] ensurePlayer created playerId=${PLAYER_ID}`) return player } private handleCompletion(): void { const completion = MusicPlaybackController.resolveCompletionAction(this.playType, this.currentIndex, this.queue.length) if (completion.action === 'replay_current') { void this.playIndex(this.currentIndex) return } if (completion.action === 'stop_current') { this.stop() return } const nextIndex = MusicPlaybackController.resolveNextQueueIndex(this.currentIndex, this.queue.length).nextIndex void this.playIndex(nextIndex) } private pause(): void { const player = this.player if (!player) { Logger.warn(TAG, '[MusicCast] pause ignored because player missing') return } player.pause() this.isPlaying = false this.stopProgressTimer() Logger.info(TAG, `[MusicCast] pause position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`) this.syncHostStorage(PlayStatus.PAUSE) this.syncPlaybackSessionState(PlayStatus.PAUSE) this.publishSnapshot(false) } private resume(): void { const player = this.player if (!player) { Logger.warn(TAG, '[MusicCast] resume ignored because player missing') return } player.start() this.isPlaying = true this.startProgressTimer() Logger.info(TAG, `[MusicCast] resume position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`) this.syncHostStorage(PlayStatus.PLAY) this.syncPlaybackSessionState(PlayStatus.PLAY) this.publishSnapshot(true) } private verifyPlaybackStarted(token: number, source: string): void { setTimeout(() => { if (token !== this.playbackStartVerifyToken || !this.player) { return } const player = this.player const isPlayingNow = player.isPlaying() const currentPosition = player.getCurrentPosition() Logger.info(TAG, `[MusicCast] verifyPlaybackStarted source=${source}, token=${token}, isPlaying=${isPlayingNow}, position=${currentPosition}`) if (isPlayingNow && currentPosition > 0) { this.isPlaying = true this.syncPlaybackSessionState(PlayStatus.PLAY) return } Logger.warn(TAG, `[MusicCast] verifyPlaybackStarted retry start source=${source}, token=${token}, ` + `isPlaying=${isPlayingNow}, position=${currentPosition}`) player.start() setTimeout(() => { if (token !== this.playbackStartVerifyToken || !this.player) { return } const retryPlaying = this.player.isPlaying() const retryPosition = this.player.getCurrentPosition() this.isPlaying = retryPlaying Logger.info(TAG, `[MusicCast] verifyPlaybackStarted afterRetry source=${source}, token=${token}, ` + `isPlaying=${retryPlaying}, position=${retryPosition}`) this.syncPlaybackSessionState(retryPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) }, PLAYBACK_VERIFY_RETRY_DELAY_MS) }, PLAYBACK_VERIFY_DELAY_MS) } private stop(): void { const player = this.player const abilityContext = this.context as common.UIAbilityContext | undefined if (player) { Logger.info(TAG, `[MusicCast] stop position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`) player.stop() } this.isPlaying = false this.isPrepared = false this.stopProgressTimer() this.syncHostStorage(PlayStatus.INIT) this.syncPlaybackSessionState(PlayStatus.INIT) this.publishSnapshot(false) BackgroundTaskManager.stopContinuousTask(abilityContext) } private seekToInternal(value: string): boolean { const player = this.player if (!player || !this.currentSong || value === '') { Logger.warn(TAG, `[MusicCast] seek ignored playerReady=${player !== undefined}, songReady=${this.currentSong !== undefined}, value=${value}`) return false } Logger.info(TAG, `[MusicCast] seekTo value=${value}, song=${this.currentSong.name}`) player.seekTo(value) return true } private startProgressTimer(): void { this.stopProgressTimer() this.progressTimerId = setInterval(() => { this.publishProgress() }, PROGRESS_INTERVAL_MS) } private stopProgressTimer(): void { if (this.progressTimerId >= 0) { clearInterval(this.progressTimerId) this.progressTimerId = -1 } } private publishProgress(): void { if (!this.player || !this.currentSong || !this.context) { return } const durationMs = this.resolveDurationMs() const positionMs = Math.max(0, this.player.getCurrentPosition()) const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100) Logger.info(TAG, `[MusicCast] progress position=${positionMs}, duration=${durationMs}, isPlaying=${this.player.isPlaying()}, ` + `audioSessionId=${this.player.getAudioSessionId()}`) MusicCardManager.getInstance().notifyProgressTick( this.context, this.currentSong, this.isPlaying, positionMs, durationMs, this.currentSong.lyricContent, this.currentSong.pixelMapPath ) this.syncPlaybackDisplayState(progressValue, positionMs, durationMs) this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.persistPlaybackSnapshot(this.isPlaying) } private async ensurePlaybackSession(): Promise { const abilityContext = this.context as common.UIAbilityContext | undefined if (!abilityContext) { Logger.warn(TAG, '[MusicCast] ensurePlaybackSession skipped because abilityContext missing') return } BackgroundTaskManager.startContinuousTask(abilityContext) if (this.playbackSession || this.creatingPlaybackSession) { return } this.creatingPlaybackSession = true try { const session = await avSession.createAVSession(abilityContext, 'music_card_background_audio', 'audio') this.playbackSession = session Logger.info(TAG, `[MusicCast] playbackSession created sessionId=${session.sessionId}`) try { await session.activate() Logger.info(TAG, '[MusicCast] playbackSession activated') } catch (error) { const err = error as BusinessError Logger.error(TAG, `[MusicCast] playbackSession activate failed code=${err.code}, message=${err.message}`) } await this.setPlaybackSessionLaunchAbility(abilityContext, session) this.registerPlaybackSessionCallbacks(session) await this.syncPlaybackSessionMetadata() this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) } catch (error) { const err = error as BusinessError Logger.error(TAG, `[MusicCast] playbackSession create failed code=${err.code}, message=${err.message}`) } finally { this.creatingPlaybackSession = false } } private async setPlaybackSessionLaunchAbility(abilityContext: common.UIAbilityContext, session: avSession.AVSession): Promise { try { const want = new Want() want.bundleName = abilityContext.abilityInfo.bundleName want.abilityName = abilityContext.abilityInfo.name const wantAgentInfo: wantAgent.WantAgentInfo = { wants: [want], operationType: wantAgent.OperationType.START_ABILITIES, requestCode: 0, wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] } const agent = await wantAgent.getWantAgent(wantAgentInfo) await session.setLaunchAbility(agent) Logger.info(TAG, '[MusicCast] playbackSession launchAbility attached') } catch (error) { const err = error as BusinessError Logger.error(TAG, `[MusicCast] playbackSession launchAbility failed code=${err.code}, message=${err.message}`) } } private registerPlaybackSessionCallbacks(session: avSession.AVSession): void { if (this.playbackSessionCallbacksRegistered) { return } session.on('play', () => { void this.handlePlaybackSessionPlay() }) session.on('pause', () => { this.handlePlaybackSessionPause() }) session.on('stop', () => { this.handlePlaybackSessionStop() }) session.on('playNext', () => { void this.playNext() }) session.on('playPrevious', () => { void this.playPrevious() }) session.on('seek', (time: number) => { void this.seekTo(`${time}`) }) session.on('setLoopMode', (_mode: number) => { void this.setLoopMode() }) session.on('toggleFavorite', (_assetId: string) => { this.toggleCurrentSongFavoriteState() }) this.playbackSessionCallbacksRegistered = true } private async handlePlaybackSessionPlay(): Promise { this.restorePersistedQueueIfNeeded() if (this.isPrepared && !this.isPlaying) { this.resume() return } if (this.currentIndex >= 0) { await this.playIndex(this.currentIndex) return } if (this.queue.length > 0) { await this.playIndex(0) } } private handlePlaybackSessionPause(): void { if (this.isPlaying) { this.pause() } } private handlePlaybackSessionStop(): void { this.stop() } private async syncPlaybackSessionMetadata(): Promise { if (!this.playbackSession || !this.currentSong) { return } try { const mediaImage = await this.resolvePlaybackSessionMediaImage() let metadata: avSession.AVMetadata = { assetId: this.currentSong.filePath ?? this.currentSong.name ?? '', title: this.currentSong.name, artist: this.currentSong.artist, mediaImage: mediaImage, duration: this.resolveDurationMs() } await this.playbackSession.setAVMetadata(metadata) Logger.info(TAG, `[MusicCast] playbackSession metadata updated title=${this.currentSong.name}, duration=${metadata.duration ?? 0}`) } catch (error) { const err = error as BusinessError Logger.error(TAG, `[MusicCast] playbackSession metadata failed code=${err.code}, message=${err.message}`) } } private async resolvePlaybackSessionMediaImage(): Promise { const coverPath = this.currentSong?.pixelMapPath ?? '' if (StrUtil.isNotEmpty(coverPath)) { if (coverPath.startsWith('http://') || coverPath.startsWith('https://')) { return coverPath } try { const localCoverPath = coverPath.startsWith('file://') ? FileUtil.getFilePath(coverPath) : coverPath return await imagePathToPixelMap(localCoverPath) } catch (error) { Logger.warn(TAG, `[MusicCast] cover pixelMap failed path=${coverPath}, message=${(error as Error).message}`) } } return await ImageUtil.getPixelMapFromMedia($r('app.media.alt')) } private syncPlaybackSessionState(status: number): void { if (!this.playbackSession || !this.player) { return } let state: avSession.PlaybackState = avSession.PlaybackState.PLAYBACK_STATE_INITIAL if (status === PlayStatus.PLAY) { state = avSession.PlaybackState.PLAYBACK_STATE_PLAY } else if (status === PlayStatus.PAUSE) { state = avSession.PlaybackState.PLAYBACK_STATE_PAUSE } else if (status === PlayStatus.INIT) { state = avSession.PlaybackState.PLAYBACK_STATE_STOP } let playbackState: avSession.AVPlaybackState = { state: state, loopMode: MusicPlaybackController.resolveAvSessionLoopMode(this.playType), isFavorite: this.currentSong?.isFav === 1, position: { elapsedTime: Math.max(0, this.player.getCurrentPosition()), updateTime: Date.now() } } this.playbackSession.setAVPlaybackState(playbackState).then(() => { Logger.info(TAG, `[MusicCast] playbackSession state=${state}, position=${playbackState.position?.elapsedTime ?? 0}`) }).catch((error: BusinessError) => { Logger.error(TAG, `[MusicCast] playbackSession state failed code=${error.code}, message=${error.message}`) }) } private handleAudioInterrupt(player: IjkMediaPlayer, event: InterruptEvent): void { Logger.info(TAG, `[MusicCast] audioInterrupt forceType=${event.forceType ?? -1}, hintType=${event.hintType ?? -1}, ` + `isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}`) switch (event.hintType) { case InterruptHintType.INTERRUPT_HINT_PAUSE: case InterruptHintType.INTERRUPT_HINT_STOP: this.isPlaying = false this.stopProgressTimer() this.syncHostStorage(PlayStatus.PAUSE) this.syncPlaybackSessionState(PlayStatus.PAUSE) this.publishSnapshot(false) break case InterruptHintType.INTERRUPT_HINT_RESUME: if (this.isPrepared && !player.isPlaying()) { player.start() this.isPlaying = true this.startProgressTimer() this.syncHostStorage(PlayStatus.PLAY) this.syncPlaybackSessionState(PlayStatus.PLAY) this.publishSnapshot(true) } break default: break } } private publishSnapshot(isPlaying: boolean): void { this.persistPlaybackSnapshot(isPlaying) if (!this.context) { return } const options = new MusicCardPlaybackStateOptions() options.currentSong = this.currentSong options.isPlaying = isPlaying options.positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0 options.durationMs = this.resolveDurationMs() options.lyricText = this.currentSong?.lyricContent options.coverPath = this.currentSong?.pixelMapPath MusicCardManager.getInstance().notifyPlaybackStateChanged( this.context, MusicCardManager.getInstance().buildSnapshotFromPlaybackState(options) ) } private persistPlaybackSnapshot(isPlaying: boolean): void { const snapshot = this.buildPlaybackSnapshot(isPlaying) Logger.info(TAG, `[MiniState] persistPlaybackSnapshot song=${snapshot.currentSongName}, index=${snapshot.currentIndex}, ` + `isPlaying=${snapshot.isPlaying}, shouldResume=${snapshot.shouldResumeWhenActivated}, ` + `position=${snapshot.positionMs}, duration=${snapshot.durationMs}`) this.snapshotStore.write(snapshot) } private buildPlaybackSnapshot(isPlaying: boolean): PlaybackSnapshot { return { queue: this.queue.map((item: VideoItem): PlaybackSnapshotSong => { return { filePath: item.filePath, id: item.id, type: item.type, name: item.name, isFav: item.isFav, artist: item.artist, album: item.album, duration: item.duration, pixelMapPath: item.pixelMapPath, lyricContent: item.lyricContent, remote_rel_path: item.remote_rel_path, webdav_account_id: item.webdav_account_id } }), currentIndex: this.currentIndex, currentSongKey: this.currentSong?.filePath ?? '', currentSongName: this.currentSong?.name ?? '', currentArtist: this.currentSong?.artist, isPlaying, positionMs: this.player ? Math.max(0, this.player.getCurrentPosition()) : 0, durationMs: this.resolveDurationMs(), cover: this.currentSong?.pixelMapPath, playType: this.playType, playlistContext: 'host_runtime', updatedAt: Date.now(), shouldResumeWhenActivated: isPlaying || this.isPrepared } } private resolveDurationMs(): number { if (this.player) { const playerDuration = this.player.getDuration() if (playerDuration > 0) { return playerDuration } } return this.parseDurationText(this.currentSong?.duration) } private parseDurationText(durationText: string | undefined): number { if (!durationText || durationText === '') { return 0 } const parts = durationText.split(':') if (parts.length < 2) { return 0 } const numbers = parts.map((part: string): number => Number.parseInt(part, 10)) if (numbers.some((value: number): boolean => Number.isNaN(value))) { return 0 } if (numbers.length === 2) { return (numbers[0] * 60 + numbers[1]) * 1000 } return (numbers[0] * 3600 + numbers[1] * 60 + numbers[2]) * 1000 } private persistCurrentQueue(): void { PreferencesUtil.putSync('LastMusicInfo', this.currentSong) PreferencesUtil.putSync('LastMusicList', this.queue) PreferencesUtil.putSync('LastPlayModeType', this.playType) PreferencesUtil.putSync('musicPlayType', this.playType) Logger.info(TAG, `[MusicCast] persist queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` + `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`) } private syncHostStorage(status: PlayStatus): void { const isPlaying = isPlaybackControlPlaying(status) const positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0 const durationMs = this.resolveDurationMs() const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100) AppStorage.setOrCreate('CONTROL_PlayStatus', status) AppStorage.setOrCreate('isPlaying', isPlaying) AppStorage.setOrCreate('animationState', isPlaying ? AnimationStatus.Running : AnimationStatus.Paused) AppStorage.setOrCreate('musicPlayType', this.playType) AppStorage.setOrCreate('songList', this.queue) AppStorage.setOrCreate('currIndex', this.currentIndex) AppStorage.setOrCreate('currentSong', this.currentSong) this.syncPlaybackDisplayState(progressValue, positionMs, durationMs) AppStorage.setOrCreate('cover', this.currentSong?.pixelMapPath ?? '') AppStorage.setOrCreate('currentSongName', this.currentSong?.name ?? '') AppStorage.setOrCreate('currentSongArtist', this.currentSong?.artist ?? '') Logger.info(TAG, `[MusicCast] syncHostStorage status=${status}, currentSong=${this.currentSong?.name ?? ''}, ` + `cover=${this.currentSong?.pixelMapPath ?? ''}`) } private syncPlaybackDisplayState(progressValue: number, positionMs: number, durationMs: number): void { AppStorage.setOrCreate('progressValue', progressValue) AppStorage.setOrCreate('playbackPositionMs', positionMs) AppStorage.setOrCreate('playbackDurationMs', durationMs) AppStorage.setOrCreate('playbackIsFavorite', this.currentSong?.isFav === 1) } private toggleCurrentSongFavoriteState(): void { const nextFavorite = this.currentSong?.isFav !== 1 this.applyCurrentSongFavoriteState(nextFavorite) } private applyCurrentSongFavoriteState(isFavorite: boolean): void { if (!this.currentSong) { return } const nextFavorite = isFavorite ? 1 : 0 this.currentSong.isFav = nextFavorite if (this.currentIndex >= 0 && this.currentIndex < this.queue.length) { this.queue[this.currentIndex].isFav = nextFavorite } this.persistCurrentQueue() this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE) this.persistPlaybackSnapshot(this.isPlaying) Logger.info(TAG, `[MusicCast] favorite updated song=${this.currentSong.name}, isFavorite=${isFavorite}`) } }