|
|
@@ -0,0 +1,640 @@
|
|
|
+import { common, Context } from '@kit.AbilityKit'
|
|
|
+import { BusinessError } from '@kit.BasicServicesKit'
|
|
|
+import { avSession } from '@kit.AVSessionKit'
|
|
|
+import { PreferencesUtil } 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 { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager'
|
|
|
+import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager'
|
|
|
+import Logger from '../common/util/Logger'
|
|
|
+import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
|
|
|
+import { MusicPlaybackController } from '../controller/MusicPlaybackController'
|
|
|
+import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
|
|
|
+import { VideoItem } from '../viewmodel/VideoItem'
|
|
|
+import {
|
|
|
+ BackgroundAudioControlDecision,
|
|
|
+ BackgroundAudioControlKind,
|
|
|
+ BackgroundAudioPersistedState,
|
|
|
+ BackgroundAudioPlaybackHostHelper,
|
|
|
+ BackgroundAudioRecoveredQueue
|
|
|
+} from './BackgroundAudioPlaybackHostHelper'
|
|
|
+
|
|
|
+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
|
|
|
+
|
|
|
+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 readonly runtime: PlaybackRuntime = {
|
|
|
+ playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> => {
|
|
|
+ await this.playQueue(queue, startIndex, source, playType)
|
|
|
+ },
|
|
|
+ playOrPause: async (): Promise<void> => {
|
|
|
+ await this.playOrPause()
|
|
|
+ },
|
|
|
+ playNext: async (): Promise<void> => {
|
|
|
+ await this.playNext()
|
|
|
+ },
|
|
|
+ playPrevious: async (): Promise<void> => {
|
|
|
+ await this.playPrevious()
|
|
|
+ },
|
|
|
+ seekTo: async (value: string): Promise<void> => {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ this.restorePersistedQueueIfNeeded()
|
|
|
+ await this.handleControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE)
|
|
|
+ }
|
|
|
+
|
|
|
+ public async playNext(): Promise<void> {
|
|
|
+ this.restorePersistedQueueIfNeeded()
|
|
|
+ await this.handleControlAction(MusicCardActionConstants.ACTION_NEXT)
|
|
|
+ }
|
|
|
+
|
|
|
+ public async playPrevious(): Promise<void> {
|
|
|
+ this.restorePersistedQueueIfNeeded()
|
|
|
+ await this.handleControlAction(MusicCardActionConstants.ACTION_PREVIOUS)
|
|
|
+ }
|
|
|
+
|
|
|
+ public async seekTo(value: string, _source?: string): Promise<void> {
|
|
|
+ this.restorePersistedQueueIfNeeded()
|
|
|
+ await this.handleControlAction(MusicCardActionConstants.ACTION_SEEK_TO, value)
|
|
|
+ }
|
|
|
+
|
|
|
+ public async handleControlAction(action: string, seekPositionMs: string = ''): Promise<boolean> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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('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)
|
|
|
+ }
|
|
|
+
|
|
|
+ private async playIndex(index: number): Promise<boolean> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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<string, string>()
|
|
|
+ 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.PLAY)
|
|
|
+ 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())
|
|
|
+ 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
|
|
|
+ )
|
|
|
+ AppStorage.setOrCreate('progressValue', positionMs)
|
|
|
+ this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
|
|
|
+ }
|
|
|
+
|
|
|
+ private async ensurePlaybackSession(): Promise<void> {
|
|
|
+ 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.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 syncPlaybackSessionMetadata(): Promise<void> {
|
|
|
+ if (!this.playbackSession || !this.currentSong) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ let metadata: avSession.AVMetadata = {
|
|
|
+ assetId: this.currentSong.filePath ?? this.currentSong.name ?? '',
|
|
|
+ title: this.currentSong.name,
|
|
|
+ artist: this.currentSong.artist,
|
|
|
+ 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 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,
|
|
|
+ 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 {
|
|
|
+ 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 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)
|
|
|
+ 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 {
|
|
|
+ AppStorage.setOrCreate('CONTROL_PlayStatus', status)
|
|
|
+ AppStorage.setOrCreate('currentSong', this.currentSong)
|
|
|
+ AppStorage.setOrCreate('cover', this.currentSong?.pixelMapPath ?? '')
|
|
|
+ Logger.info(TAG,
|
|
|
+ `[MusicCast] syncHostStorage status=${status}, currentSong=${this.currentSong?.name ?? ''}, ` +
|
|
|
+ `cover=${this.currentSong?.pixelMapPath ?? ''}`)
|
|
|
+ }
|
|
|
+}
|