BackgroundAudioPlaybackHost.ets 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. import { common, Context, wantAgent, Want } from '@kit.AbilityKit'
  2. import { BusinessError } from '@kit.BasicServicesKit'
  3. import { avSession } from '@kit.AVSessionKit'
  4. import { image } from '@kit.ImageKit'
  5. import { FileUtil, ImageUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils'
  6. import {
  7. DeviceChangeReason,
  8. InterruptEvent,
  9. InterruptHintType,
  10. IjkMediaPlayer
  11. } from '@ohos/ijkplayer'
  12. import {
  13. ImplOnCompletionListener,
  14. ImplOnErrorListener,
  15. ImplOnPreparedListener,
  16. ImplOnSeekCompleteListener
  17. } from '../common/IjkPlayerListenerImpls'
  18. import { PlayStatus } from '../common/PlayStatus'
  19. import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
  20. import { isPlaybackControlPlaying } from '../common/player/PlaybackControlStateHelper'
  21. import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager'
  22. import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager'
  23. import Logger from '../common/util/Logger'
  24. import { imagePathToPixelMap } from '../common/util/CommUtils'
  25. import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
  26. import { MusicPlaybackController } from '../controller/MusicPlaybackController'
  27. import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
  28. import { VideoItem } from '../viewmodel/VideoItem'
  29. import { PlaybackSnapshotStore } from './PlaybackSnapshotStore'
  30. import {
  31. BackgroundAudioControlDecision,
  32. BackgroundAudioControlKind,
  33. BackgroundAudioPersistedState,
  34. BackgroundAudioPlaybackHostHelper,
  35. BackgroundAudioRecoveredQueue
  36. } from './BackgroundAudioPlaybackHostHelper'
  37. import { PlaybackSnapshot, PlaybackSnapshotSong, resolvePlaybackSnapshotProgressValue } from './model/PlaybackSnapshot'
  38. const TAG = 'BackgroundAudioHost'
  39. const PLAYER_ID = 'audioIjkId'
  40. const PROGRESS_INTERVAL_MS = 1000
  41. const PLAYBACK_VERIFY_DELAY_MS = 200
  42. const PLAYBACK_VERIFY_RETRY_DELAY_MS = 350
  43. interface PlaybackSnapshotWriter {
  44. write(snapshot: PlaybackSnapshot): void
  45. }
  46. export class BackgroundAudioPlaybackHost {
  47. private static instance: BackgroundAudioPlaybackHost
  48. private context: common.Context | undefined = undefined
  49. private player: IjkMediaPlayer | undefined = undefined
  50. private queue: VideoItem[] = []
  51. private currentIndex: number = -1
  52. private currentSong: VideoItem | undefined = undefined
  53. private playType: number = 0
  54. private isPlaying: boolean = false
  55. private isPrepared: boolean = false
  56. private currentUrl: string = ''
  57. private progressTimerId: number = -1
  58. private playbackStartVerifyToken: number = 0
  59. private playbackSession: avSession.AVSession | undefined = undefined
  60. private creatingPlaybackSession: boolean = false
  61. private playbackSessionCallbacksRegistered: boolean = false
  62. private snapshotStore: PlaybackSnapshotWriter = new PlaybackSnapshotStore()
  63. private readonly runtime: PlaybackRuntime = {
  64. playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> => {
  65. await this.playQueue(queue, startIndex, source, playType)
  66. },
  67. playOrPause: async (): Promise<void> => {
  68. await this.playOrPause()
  69. },
  70. playNext: async (): Promise<void> => {
  71. await this.playNext()
  72. },
  73. playPrevious: async (): Promise<void> => {
  74. await this.playPrevious()
  75. },
  76. setLoopMode: async (): Promise<void> => {
  77. await this.setLoopMode()
  78. },
  79. seekTo: async (value: string): Promise<void> => {
  80. await this.seekTo(value)
  81. }
  82. }
  83. public static getInstance(): BackgroundAudioPlaybackHost {
  84. if (!BackgroundAudioPlaybackHost.instance) {
  85. BackgroundAudioPlaybackHost.instance = new BackgroundAudioPlaybackHost()
  86. }
  87. return BackgroundAudioPlaybackHost.instance
  88. }
  89. public setContext(context: common.Context | undefined): void {
  90. this.context = context
  91. Logger.info(TAG, `[MusicCast] setContext contextReady=${context !== undefined}`)
  92. }
  93. public getRuntime(): PlaybackRuntime {
  94. return this.runtime
  95. }
  96. public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
  97. if (queue.length <= 0) {
  98. Logger.warn(TAG, `[MusicCast] playQueue ignored empty queue source=${source}`)
  99. return
  100. }
  101. const safeIndex = Math.max(0, Math.min(startIndex, queue.length - 1))
  102. this.queue = [...queue]
  103. this.currentIndex = safeIndex
  104. this.currentSong = this.queue[safeIndex]
  105. if (playType !== undefined) {
  106. this.playType = playType
  107. }
  108. Logger.info(TAG,
  109. `[MusicCast] playQueue source=${source}, queueLength=${this.queue.length}, startIndex=${startIndex}, ` +
  110. `safeIndex=${safeIndex}, playType=${this.playType}`)
  111. this.persistCurrentQueue()
  112. await this.playIndex(safeIndex)
  113. }
  114. public async playOrPause(): Promise<void> {
  115. this.restorePersistedQueueIfNeeded()
  116. await this.handleControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE)
  117. }
  118. public async playNext(): Promise<void> {
  119. this.restorePersistedQueueIfNeeded()
  120. await this.handleControlAction(MusicCardActionConstants.ACTION_NEXT)
  121. }
  122. public async playPrevious(): Promise<void> {
  123. this.restorePersistedQueueIfNeeded()
  124. await this.handleControlAction(MusicCardActionConstants.ACTION_PREVIOUS)
  125. }
  126. public async seekTo(value: string, _source?: string): Promise<void> {
  127. this.restorePersistedQueueIfNeeded()
  128. await this.handleControlAction(MusicCardActionConstants.ACTION_SEEK_TO, value)
  129. }
  130. public async setLoopMode(): Promise<void> {
  131. this.restorePersistedQueueIfNeeded()
  132. const nextMode = MusicPlaybackController.resolveNextLoopMode(this.playType)
  133. this.playType = nextMode.playType
  134. PreferencesUtil.putSync('musicPlayType', this.playType)
  135. this.persistCurrentQueue()
  136. this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  137. this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  138. this.persistPlaybackSnapshot(this.isPlaying)
  139. Logger.info(TAG, `[MusicCast] setLoopMode playType=${this.playType}, toast=${nextMode.toastText}`)
  140. }
  141. public syncCurrentSongFavoriteState(isFavorite: boolean): void {
  142. this.applyCurrentSongFavoriteState(isFavorite)
  143. }
  144. public getSpectrumData(): number[] {
  145. if (!this.player) {
  146. return []
  147. }
  148. return this.player.getSpectrumData()
  149. }
  150. public async handleControlAction(action: string, seekPositionMs: string = ''): Promise<boolean> {
  151. try {
  152. Logger.info(TAG,
  153. `[MusicCast] handleControlAction action=${action}, seek=${seekPositionMs}, queueLength=${this.queue.length}, ` +
  154. `currentIndex=${this.currentIndex}, isPlaying=${this.isPlaying}, isPrepared=${this.isPrepared}`)
  155. this.restorePersistedQueueIfNeeded()
  156. if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
  157. const handled = this.seekToInternal(seekPositionMs)
  158. Logger.info(TAG, `[MusicCast] seek decision handled=${handled}, seek=${seekPositionMs}`)
  159. return handled
  160. }
  161. const decision = BackgroundAudioPlaybackHostHelper.resolveControlDecision(
  162. action,
  163. this.currentIndex,
  164. this.queue.length,
  165. this.isPlaying,
  166. this.playType
  167. )
  168. Logger.info(TAG,
  169. `[MusicCast] decision kind=${decision.kind}, targetIndex=${decision.targetIndex}, ` +
  170. `queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, playType=${this.playType}`)
  171. return this.executeDecision(decision)
  172. } catch (error) {
  173. Logger.error(TAG, `[MusicCast] handleControlAction failed: ${(error as Error).message}`)
  174. return false
  175. }
  176. }
  177. private async executeDecision(decision: BackgroundAudioControlDecision): Promise<boolean> {
  178. if (decision.kind === BackgroundAudioControlKind.PAUSE) {
  179. this.pause()
  180. return true
  181. }
  182. if (decision.kind === BackgroundAudioControlKind.STOP) {
  183. this.stop()
  184. return true
  185. }
  186. if (decision.kind === BackgroundAudioControlKind.PLAY_INDEX) {
  187. if (!this.isPlaying && this.isPrepared && decision.targetIndex === this.currentIndex) {
  188. this.resume()
  189. return true
  190. }
  191. return this.playIndex(decision.targetIndex)
  192. }
  193. return false
  194. }
  195. private restorePersistedQueueIfNeeded(): void {
  196. if (this.queue.length > 0 && this.currentIndex >= 0 && this.currentIndex < this.queue.length) {
  197. Logger.info(TAG,
  198. `[MusicCast] skip restore because queue already ready queueLength=${this.queue.length}, currentIndex=${this.currentIndex}`)
  199. return
  200. }
  201. const persistedState: BackgroundAudioPersistedState = BackgroundAudioPlaybackHostHelper.readPersistedState(
  202. () => PreferencesUtil.getSync('LastMusicList', []) as VideoItem[],
  203. () => PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem | undefined,
  204. () => PreferencesUtil.getNumberSync('LastPlayModeType', PreferencesUtil.getNumberSync('musicPlayType', 0))
  205. )
  206. if (persistedState.errorMessage) {
  207. Logger.error(TAG, `[MusicCast] restore persisted read failed: ${persistedState.errorMessage}`)
  208. }
  209. Logger.info(TAG,
  210. `[MusicCast] restore persisted queueLength=${persistedState.queue.length}, currentSong=${persistedState.currentSong?.name ?? ''}, ` +
  211. `path=${persistedState.currentSong?.filePath ?? ''}, playType=${persistedState.playType}`)
  212. const restored = BackgroundAudioPlaybackHostHelper.restorePersistedQueue(
  213. persistedState.queue,
  214. persistedState.currentSong,
  215. persistedState.playType
  216. )
  217. this.applyRecoveredQueue(restored)
  218. }
  219. private applyRecoveredQueue(restored: BackgroundAudioRecoveredQueue): void {
  220. this.queue = restored.queue
  221. this.currentIndex = restored.currentIndex
  222. this.currentSong = restored.currentSong
  223. this.playType = restored.playType
  224. Logger.info(TAG,
  225. `[MusicCast] applyRecoveredQueue queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` +
  226. `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`)
  227. this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  228. this.persistPlaybackSnapshot(this.isPlaying)
  229. }
  230. private async playIndex(index: number): Promise<boolean> {
  231. if (index < 0 || index >= this.queue.length) {
  232. Logger.warn(TAG, `[MusicCast] playIndex ignored invalid index=${index}, queueLength=${this.queue.length}`)
  233. return false
  234. }
  235. this.currentIndex = index
  236. this.currentSong = this.queue[index]
  237. Logger.info(TAG,
  238. `[MusicCast] playIndex index=${index}, song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, type=${this.currentSong?.type ?? -1}`)
  239. this.persistCurrentQueue()
  240. return this.prepareAndStartCurrentSong()
  241. }
  242. private async prepareAndStartCurrentSong(): Promise<boolean> {
  243. if (!this.currentSong) {
  244. Logger.warn(TAG, '[MusicCast] prepare skipped because currentSong missing')
  245. return false
  246. }
  247. if (!this.context) {
  248. Logger.warn(TAG, '[MusicCast] prepare skipped because context missing')
  249. return false
  250. }
  251. try {
  252. await this.ensurePlaybackSession()
  253. const player = this.ensurePlayer()
  254. this.playbackStartVerifyToken++
  255. this.isPrepared = false
  256. this.isPlaying = false
  257. this.currentUrl = await setVideoUrlForSong(this.currentSong, {
  258. context: this.context as Context,
  259. extractAudioInfo: false,
  260. extractCover: false,
  261. extractLyric: false
  262. })
  263. Logger.info(TAG,
  264. `[MusicCast] prepare resolved url=${this.currentUrl}, song=${this.currentSong.name}, path=${this.currentSong.filePath}`)
  265. player.reset()
  266. player.setAudioId(PLAYER_ID)
  267. player.native_setup()
  268. player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'start-on-prepared', '1')
  269. player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'packet-buffering', '0')
  270. player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'soundtouch', '1')
  271. player.setVolume('1', '1')
  272. player.setDataSource(this.currentUrl)
  273. const headers = new Map<string, string>()
  274. headers.set('User-Agent', 'TTMusic-Widget/1.0')
  275. headers.set('Accept', '*/*')
  276. player.setDataSourceHeader(headers)
  277. void this.syncPlaybackSessionMetadata()
  278. player.prepareAsync()
  279. player.start()
  280. Logger.info(TAG, `[MusicCast] prepare issued start song=${this.currentSong.name}, playerId=${PLAYER_ID}`)
  281. this.syncHostStorage(PlayStatus.PAUSE)
  282. this.publishSnapshot(false)
  283. return true
  284. } catch (error) {
  285. Logger.error(TAG, `[MusicCast] prepare failed: ${(error as Error).message}`)
  286. this.isPrepared = false
  287. this.isPlaying = false
  288. this.syncHostStorage(PlayStatus.PAUSE)
  289. this.publishSnapshot(false)
  290. return false
  291. }
  292. }
  293. private ensurePlayer(): IjkMediaPlayer {
  294. if (this.player) {
  295. return this.player
  296. }
  297. const player = new IjkMediaPlayer()
  298. player.setAudioId(PLAYER_ID)
  299. player.native_setup()
  300. player.setOnPreparedListener(new ImplOnPreparedListener(() => {
  301. this.isPrepared = true
  302. player.start()
  303. this.isPlaying = player.isPlaying()
  304. Logger.info(TAG,
  305. `[MusicCast] onPrepared song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, ` +
  306. `duration=${player.getDuration()}, isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}, ` +
  307. `audioSessionId=${player.getAudioSessionId()}`)
  308. this.startProgressTimer()
  309. this.verifyPlaybackStarted(this.playbackStartVerifyToken, 'onPrepared')
  310. this.syncHostStorage(PlayStatus.PLAY)
  311. this.syncPlaybackSessionState(PlayStatus.PLAY)
  312. this.publishSnapshot(true)
  313. }))
  314. player.setOnCompletionListener(new ImplOnCompletionListener(() => {
  315. Logger.info(TAG,
  316. `[MusicCast] onCompletion currentIndex=${this.currentIndex}, queueLength=${this.queue.length}, playType=${this.playType}`)
  317. this.handleCompletion()
  318. }))
  319. player.setOnErrorListener(new ImplOnErrorListener((what: number, extra: number) => {
  320. Logger.error(TAG, `[MusicCast] player error what=${what}, extra=${extra}, url=${this.currentUrl}`)
  321. this.isPrepared = false
  322. this.isPlaying = false
  323. this.stopProgressTimer()
  324. this.syncHostStorage(PlayStatus.PAUSE)
  325. this.syncPlaybackSessionState(PlayStatus.PAUSE)
  326. this.publishSnapshot(false)
  327. }))
  328. player.setOnSeekCompleteListener(new ImplOnSeekCompleteListener(() => {
  329. Logger.info(TAG, `[MusicCast] onSeekComplete position=${player.getCurrentPosition()}`)
  330. this.publishProgress()
  331. }))
  332. player.on('audioInterrupt', (event: InterruptEvent) => {
  333. this.handleAudioInterrupt(player, event)
  334. })
  335. player.on('deviceChange', (event: InterruptEvent) => {
  336. Logger.info(TAG, `[MusicCast] deviceChange reason=${event.reason ?? DeviceChangeReason.REASON_UNKNOWN}`)
  337. })
  338. player.setMessageListener()
  339. this.player = player
  340. Logger.info(TAG, `[MusicCast] ensurePlayer created playerId=${PLAYER_ID}`)
  341. return player
  342. }
  343. private handleCompletion(): void {
  344. const completion = MusicPlaybackController.resolveCompletionAction(this.playType, this.currentIndex, this.queue.length)
  345. if (completion.action === 'replay_current') {
  346. void this.playIndex(this.currentIndex)
  347. return
  348. }
  349. if (completion.action === 'stop_current') {
  350. this.stop()
  351. return
  352. }
  353. const nextIndex = MusicPlaybackController.resolveNextQueueIndex(this.currentIndex, this.queue.length).nextIndex
  354. void this.playIndex(nextIndex)
  355. }
  356. private pause(): void {
  357. const player = this.player
  358. if (!player) {
  359. Logger.warn(TAG, '[MusicCast] pause ignored because player missing')
  360. return
  361. }
  362. player.pause()
  363. this.isPlaying = false
  364. this.stopProgressTimer()
  365. Logger.info(TAG, `[MusicCast] pause position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
  366. this.syncHostStorage(PlayStatus.PAUSE)
  367. this.syncPlaybackSessionState(PlayStatus.PAUSE)
  368. this.publishSnapshot(false)
  369. }
  370. private resume(): void {
  371. const player = this.player
  372. if (!player) {
  373. Logger.warn(TAG, '[MusicCast] resume ignored because player missing')
  374. return
  375. }
  376. player.start()
  377. this.isPlaying = true
  378. this.startProgressTimer()
  379. Logger.info(TAG, `[MusicCast] resume position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
  380. this.syncHostStorage(PlayStatus.PLAY)
  381. this.syncPlaybackSessionState(PlayStatus.PLAY)
  382. this.publishSnapshot(true)
  383. }
  384. private verifyPlaybackStarted(token: number, source: string): void {
  385. setTimeout(() => {
  386. if (token !== this.playbackStartVerifyToken || !this.player) {
  387. return
  388. }
  389. const player = this.player
  390. const isPlayingNow = player.isPlaying()
  391. const currentPosition = player.getCurrentPosition()
  392. Logger.info(TAG,
  393. `[MusicCast] verifyPlaybackStarted source=${source}, token=${token}, isPlaying=${isPlayingNow}, position=${currentPosition}`)
  394. if (isPlayingNow && currentPosition > 0) {
  395. this.isPlaying = true
  396. this.syncPlaybackSessionState(PlayStatus.PLAY)
  397. return
  398. }
  399. Logger.warn(TAG,
  400. `[MusicCast] verifyPlaybackStarted retry start source=${source}, token=${token}, ` +
  401. `isPlaying=${isPlayingNow}, position=${currentPosition}`)
  402. player.start()
  403. setTimeout(() => {
  404. if (token !== this.playbackStartVerifyToken || !this.player) {
  405. return
  406. }
  407. const retryPlaying = this.player.isPlaying()
  408. const retryPosition = this.player.getCurrentPosition()
  409. this.isPlaying = retryPlaying
  410. Logger.info(TAG,
  411. `[MusicCast] verifyPlaybackStarted afterRetry source=${source}, token=${token}, ` +
  412. `isPlaying=${retryPlaying}, position=${retryPosition}`)
  413. this.syncPlaybackSessionState(retryPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  414. }, PLAYBACK_VERIFY_RETRY_DELAY_MS)
  415. }, PLAYBACK_VERIFY_DELAY_MS)
  416. }
  417. private stop(): void {
  418. const player = this.player
  419. const abilityContext = this.context as common.UIAbilityContext | undefined
  420. if (player) {
  421. Logger.info(TAG, `[MusicCast] stop position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
  422. player.stop()
  423. }
  424. this.isPlaying = false
  425. this.isPrepared = false
  426. this.stopProgressTimer()
  427. this.syncHostStorage(PlayStatus.INIT)
  428. this.syncPlaybackSessionState(PlayStatus.INIT)
  429. this.publishSnapshot(false)
  430. BackgroundTaskManager.stopContinuousTask(abilityContext)
  431. }
  432. private seekToInternal(value: string): boolean {
  433. const player = this.player
  434. if (!player || !this.currentSong || value === '') {
  435. Logger.warn(TAG,
  436. `[MusicCast] seek ignored playerReady=${player !== undefined}, songReady=${this.currentSong !== undefined}, value=${value}`)
  437. return false
  438. }
  439. Logger.info(TAG, `[MusicCast] seekTo value=${value}, song=${this.currentSong.name}`)
  440. player.seekTo(value)
  441. return true
  442. }
  443. private startProgressTimer(): void {
  444. this.stopProgressTimer()
  445. this.progressTimerId = setInterval(() => {
  446. this.publishProgress()
  447. }, PROGRESS_INTERVAL_MS)
  448. }
  449. private stopProgressTimer(): void {
  450. if (this.progressTimerId >= 0) {
  451. clearInterval(this.progressTimerId)
  452. this.progressTimerId = -1
  453. }
  454. }
  455. private publishProgress(): void {
  456. if (!this.player || !this.currentSong || !this.context) {
  457. return
  458. }
  459. const durationMs = this.resolveDurationMs()
  460. const positionMs = Math.max(0, this.player.getCurrentPosition())
  461. const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
  462. Logger.info(TAG,
  463. `[MusicCast] progress position=${positionMs}, duration=${durationMs}, isPlaying=${this.player.isPlaying()}, ` +
  464. `audioSessionId=${this.player.getAudioSessionId()}`)
  465. MusicCardManager.getInstance().notifyProgressTick(
  466. this.context,
  467. this.currentSong,
  468. this.isPlaying,
  469. positionMs,
  470. durationMs,
  471. this.currentSong.lyricContent,
  472. this.currentSong.pixelMapPath
  473. )
  474. this.syncPlaybackDisplayState(progressValue, positionMs, durationMs)
  475. this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  476. this.persistPlaybackSnapshot(this.isPlaying)
  477. }
  478. private async ensurePlaybackSession(): Promise<void> {
  479. const abilityContext = this.context as common.UIAbilityContext | undefined
  480. if (!abilityContext) {
  481. Logger.warn(TAG, '[MusicCast] ensurePlaybackSession skipped because abilityContext missing')
  482. return
  483. }
  484. BackgroundTaskManager.startContinuousTask(abilityContext)
  485. if (this.playbackSession || this.creatingPlaybackSession) {
  486. return
  487. }
  488. this.creatingPlaybackSession = true
  489. try {
  490. const session = await avSession.createAVSession(abilityContext, 'music_card_background_audio', 'audio')
  491. this.playbackSession = session
  492. Logger.info(TAG, `[MusicCast] playbackSession created sessionId=${session.sessionId}`)
  493. try {
  494. await session.activate()
  495. Logger.info(TAG, '[MusicCast] playbackSession activated')
  496. } catch (error) {
  497. const err = error as BusinessError
  498. Logger.error(TAG, `[MusicCast] playbackSession activate failed code=${err.code}, message=${err.message}`)
  499. }
  500. await this.setPlaybackSessionLaunchAbility(abilityContext, session)
  501. this.registerPlaybackSessionCallbacks(session)
  502. await this.syncPlaybackSessionMetadata()
  503. this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  504. } catch (error) {
  505. const err = error as BusinessError
  506. Logger.error(TAG, `[MusicCast] playbackSession create failed code=${err.code}, message=${err.message}`)
  507. } finally {
  508. this.creatingPlaybackSession = false
  509. }
  510. }
  511. private async setPlaybackSessionLaunchAbility(abilityContext: common.UIAbilityContext,
  512. session: avSession.AVSession): Promise<void> {
  513. try {
  514. const want = new Want()
  515. want.bundleName = abilityContext.abilityInfo.bundleName
  516. want.abilityName = abilityContext.abilityInfo.name
  517. const wantAgentInfo: wantAgent.WantAgentInfo = {
  518. wants: [want],
  519. operationType: wantAgent.OperationType.START_ABILITIES,
  520. requestCode: 0,
  521. wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
  522. }
  523. const agent = await wantAgent.getWantAgent(wantAgentInfo)
  524. await session.setLaunchAbility(agent)
  525. Logger.info(TAG, '[MusicCast] playbackSession launchAbility attached')
  526. } catch (error) {
  527. const err = error as BusinessError
  528. Logger.error(TAG, `[MusicCast] playbackSession launchAbility failed code=${err.code}, message=${err.message}`)
  529. }
  530. }
  531. private registerPlaybackSessionCallbacks(session: avSession.AVSession): void {
  532. if (this.playbackSessionCallbacksRegistered) {
  533. return
  534. }
  535. session.on('play', () => {
  536. void this.handlePlaybackSessionPlay()
  537. })
  538. session.on('pause', () => {
  539. this.handlePlaybackSessionPause()
  540. })
  541. session.on('stop', () => {
  542. this.handlePlaybackSessionStop()
  543. })
  544. session.on('playNext', () => {
  545. void this.playNext()
  546. })
  547. session.on('playPrevious', () => {
  548. void this.playPrevious()
  549. })
  550. session.on('seek', (time: number) => {
  551. void this.seekTo(`${time}`)
  552. })
  553. session.on('setLoopMode', (_mode: number) => {
  554. void this.setLoopMode()
  555. })
  556. session.on('toggleFavorite', (_assetId: string) => {
  557. this.toggleCurrentSongFavoriteState()
  558. })
  559. this.playbackSessionCallbacksRegistered = true
  560. }
  561. private async handlePlaybackSessionPlay(): Promise<void> {
  562. this.restorePersistedQueueIfNeeded()
  563. if (this.isPrepared && !this.isPlaying) {
  564. this.resume()
  565. return
  566. }
  567. if (this.currentIndex >= 0) {
  568. await this.playIndex(this.currentIndex)
  569. return
  570. }
  571. if (this.queue.length > 0) {
  572. await this.playIndex(0)
  573. }
  574. }
  575. private handlePlaybackSessionPause(): void {
  576. if (this.isPlaying) {
  577. this.pause()
  578. }
  579. }
  580. private handlePlaybackSessionStop(): void {
  581. this.stop()
  582. }
  583. private async syncPlaybackSessionMetadata(): Promise<void> {
  584. if (!this.playbackSession || !this.currentSong) {
  585. return
  586. }
  587. try {
  588. const mediaImage = await this.resolvePlaybackSessionMediaImage()
  589. let metadata: avSession.AVMetadata = {
  590. assetId: this.currentSong.filePath ?? this.currentSong.name ?? '',
  591. title: this.currentSong.name,
  592. artist: this.currentSong.artist,
  593. mediaImage: mediaImage,
  594. duration: this.resolveDurationMs()
  595. }
  596. await this.playbackSession.setAVMetadata(metadata)
  597. Logger.info(TAG,
  598. `[MusicCast] playbackSession metadata updated title=${this.currentSong.name}, duration=${metadata.duration ?? 0}`)
  599. } catch (error) {
  600. const err = error as BusinessError
  601. Logger.error(TAG, `[MusicCast] playbackSession metadata failed code=${err.code}, message=${err.message}`)
  602. }
  603. }
  604. private async resolvePlaybackSessionMediaImage(): Promise<image.PixelMap | string> {
  605. const coverPath = this.currentSong?.pixelMapPath ?? ''
  606. if (StrUtil.isNotEmpty(coverPath)) {
  607. if (coverPath.startsWith('http://') || coverPath.startsWith('https://')) {
  608. return coverPath
  609. }
  610. try {
  611. const localCoverPath = coverPath.startsWith('file://') ? FileUtil.getFilePath(coverPath) : coverPath
  612. return await imagePathToPixelMap(localCoverPath)
  613. } catch (error) {
  614. Logger.warn(TAG, `[MusicCast] cover pixelMap failed path=${coverPath}, message=${(error as Error).message}`)
  615. }
  616. }
  617. return await ImageUtil.getPixelMapFromMedia($r('app.media.alt'))
  618. }
  619. private syncPlaybackSessionState(status: number): void {
  620. if (!this.playbackSession || !this.player) {
  621. return
  622. }
  623. let state: avSession.PlaybackState = avSession.PlaybackState.PLAYBACK_STATE_INITIAL
  624. if (status === PlayStatus.PLAY) {
  625. state = avSession.PlaybackState.PLAYBACK_STATE_PLAY
  626. } else if (status === PlayStatus.PAUSE) {
  627. state = avSession.PlaybackState.PLAYBACK_STATE_PAUSE
  628. } else if (status === PlayStatus.INIT) {
  629. state = avSession.PlaybackState.PLAYBACK_STATE_STOP
  630. }
  631. let playbackState: avSession.AVPlaybackState = {
  632. state: state,
  633. loopMode: MusicPlaybackController.resolveAvSessionLoopMode(this.playType),
  634. isFavorite: this.currentSong?.isFav === 1,
  635. position: {
  636. elapsedTime: Math.max(0, this.player.getCurrentPosition()),
  637. updateTime: Date.now()
  638. }
  639. }
  640. this.playbackSession.setAVPlaybackState(playbackState).then(() => {
  641. Logger.info(TAG,
  642. `[MusicCast] playbackSession state=${state}, position=${playbackState.position?.elapsedTime ?? 0}`)
  643. }).catch((error: BusinessError) => {
  644. Logger.error(TAG, `[MusicCast] playbackSession state failed code=${error.code}, message=${error.message}`)
  645. })
  646. }
  647. private handleAudioInterrupt(player: IjkMediaPlayer, event: InterruptEvent): void {
  648. Logger.info(TAG,
  649. `[MusicCast] audioInterrupt forceType=${event.forceType ?? -1}, hintType=${event.hintType ?? -1}, ` +
  650. `isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}`)
  651. switch (event.hintType) {
  652. case InterruptHintType.INTERRUPT_HINT_PAUSE:
  653. case InterruptHintType.INTERRUPT_HINT_STOP:
  654. this.isPlaying = false
  655. this.stopProgressTimer()
  656. this.syncHostStorage(PlayStatus.PAUSE)
  657. this.syncPlaybackSessionState(PlayStatus.PAUSE)
  658. this.publishSnapshot(false)
  659. break
  660. case InterruptHintType.INTERRUPT_HINT_RESUME:
  661. if (this.isPrepared && !player.isPlaying()) {
  662. player.start()
  663. this.isPlaying = true
  664. this.startProgressTimer()
  665. this.syncHostStorage(PlayStatus.PLAY)
  666. this.syncPlaybackSessionState(PlayStatus.PLAY)
  667. this.publishSnapshot(true)
  668. }
  669. break
  670. default:
  671. break
  672. }
  673. }
  674. private publishSnapshot(isPlaying: boolean): void {
  675. this.persistPlaybackSnapshot(isPlaying)
  676. if (!this.context) {
  677. return
  678. }
  679. const options = new MusicCardPlaybackStateOptions()
  680. options.currentSong = this.currentSong
  681. options.isPlaying = isPlaying
  682. options.positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0
  683. options.durationMs = this.resolveDurationMs()
  684. options.lyricText = this.currentSong?.lyricContent
  685. options.coverPath = this.currentSong?.pixelMapPath
  686. MusicCardManager.getInstance().notifyPlaybackStateChanged(
  687. this.context,
  688. MusicCardManager.getInstance().buildSnapshotFromPlaybackState(options)
  689. )
  690. }
  691. private persistPlaybackSnapshot(isPlaying: boolean): void {
  692. const snapshot = this.buildPlaybackSnapshot(isPlaying)
  693. Logger.info(TAG,
  694. `[MiniState] persistPlaybackSnapshot song=${snapshot.currentSongName}, index=${snapshot.currentIndex}, ` +
  695. `isPlaying=${snapshot.isPlaying}, shouldResume=${snapshot.shouldResumeWhenActivated}, ` +
  696. `position=${snapshot.positionMs}, duration=${snapshot.durationMs}`)
  697. this.snapshotStore.write(snapshot)
  698. }
  699. private buildPlaybackSnapshot(isPlaying: boolean): PlaybackSnapshot {
  700. return {
  701. queue: this.queue.map((item: VideoItem): PlaybackSnapshotSong => {
  702. return {
  703. filePath: item.filePath,
  704. id: item.id,
  705. type: item.type,
  706. name: item.name,
  707. isFav: item.isFav,
  708. artist: item.artist,
  709. album: item.album,
  710. duration: item.duration,
  711. pixelMapPath: item.pixelMapPath,
  712. lyricContent: item.lyricContent,
  713. remote_rel_path: item.remote_rel_path,
  714. webdav_account_id: item.webdav_account_id
  715. }
  716. }),
  717. currentIndex: this.currentIndex,
  718. currentSongKey: this.currentSong?.filePath ?? '',
  719. currentSongName: this.currentSong?.name ?? '',
  720. currentArtist: this.currentSong?.artist,
  721. isPlaying,
  722. positionMs: this.player ? Math.max(0, this.player.getCurrentPosition()) : 0,
  723. durationMs: this.resolveDurationMs(),
  724. cover: this.currentSong?.pixelMapPath,
  725. playType: this.playType,
  726. playlistContext: 'host_runtime',
  727. updatedAt: Date.now(),
  728. shouldResumeWhenActivated: isPlaying || this.isPrepared
  729. }
  730. }
  731. private resolveDurationMs(): number {
  732. if (this.player) {
  733. const playerDuration = this.player.getDuration()
  734. if (playerDuration > 0) {
  735. return playerDuration
  736. }
  737. }
  738. return this.parseDurationText(this.currentSong?.duration)
  739. }
  740. private parseDurationText(durationText: string | undefined): number {
  741. if (!durationText || durationText === '') {
  742. return 0
  743. }
  744. const parts = durationText.split(':')
  745. if (parts.length < 2) {
  746. return 0
  747. }
  748. const numbers = parts.map((part: string): number => Number.parseInt(part, 10))
  749. if (numbers.some((value: number): boolean => Number.isNaN(value))) {
  750. return 0
  751. }
  752. if (numbers.length === 2) {
  753. return (numbers[0] * 60 + numbers[1]) * 1000
  754. }
  755. return (numbers[0] * 3600 + numbers[1] * 60 + numbers[2]) * 1000
  756. }
  757. private persistCurrentQueue(): void {
  758. PreferencesUtil.putSync('LastMusicInfo', this.currentSong)
  759. PreferencesUtil.putSync('LastMusicList', this.queue)
  760. PreferencesUtil.putSync('LastPlayModeType', this.playType)
  761. PreferencesUtil.putSync('musicPlayType', this.playType)
  762. Logger.info(TAG,
  763. `[MusicCast] persist queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` +
  764. `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`)
  765. }
  766. private syncHostStorage(status: PlayStatus): void {
  767. const isPlaying = isPlaybackControlPlaying(status)
  768. const positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0
  769. const durationMs = this.resolveDurationMs()
  770. const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
  771. AppStorage.setOrCreate('CONTROL_PlayStatus', status)
  772. AppStorage.setOrCreate('isPlaying', isPlaying)
  773. AppStorage.setOrCreate('animationState', isPlaying ? AnimationStatus.Running : AnimationStatus.Paused)
  774. AppStorage.setOrCreate('musicPlayType', this.playType)
  775. AppStorage.setOrCreate('songList', this.queue)
  776. AppStorage.setOrCreate('currIndex', this.currentIndex)
  777. AppStorage.setOrCreate('currentSong', this.currentSong)
  778. this.syncPlaybackDisplayState(progressValue, positionMs, durationMs)
  779. AppStorage.setOrCreate('cover', this.currentSong?.pixelMapPath ?? '')
  780. AppStorage.setOrCreate('currentSongName', this.currentSong?.name ?? '')
  781. AppStorage.setOrCreate('currentSongArtist', this.currentSong?.artist ?? '')
  782. Logger.info(TAG,
  783. `[MusicCast] syncHostStorage status=${status}, currentSong=${this.currentSong?.name ?? ''}, ` +
  784. `cover=${this.currentSong?.pixelMapPath ?? ''}`)
  785. }
  786. private syncPlaybackDisplayState(progressValue: number, positionMs: number, durationMs: number): void {
  787. AppStorage.setOrCreate('progressValue', progressValue)
  788. AppStorage.setOrCreate('playbackPositionMs', positionMs)
  789. AppStorage.setOrCreate('playbackDurationMs', durationMs)
  790. AppStorage.setOrCreate('playbackIsFavorite', this.currentSong?.isFav === 1)
  791. }
  792. private toggleCurrentSongFavoriteState(): void {
  793. const nextFavorite = this.currentSong?.isFav !== 1
  794. this.applyCurrentSongFavoriteState(nextFavorite)
  795. }
  796. private applyCurrentSongFavoriteState(isFavorite: boolean): void {
  797. if (!this.currentSong) {
  798. return
  799. }
  800. const nextFavorite = isFavorite ? 1 : 0
  801. this.currentSong.isFav = nextFavorite
  802. if (this.currentIndex >= 0 && this.currentIndex < this.queue.length) {
  803. this.queue[this.currentIndex].isFav = nextFavorite
  804. }
  805. this.persistCurrentQueue()
  806. this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  807. this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
  808. this.persistPlaybackSnapshot(this.isPlaying)
  809. Logger.info(TAG, `[MusicCast] favorite updated song=${this.currentSong.name}, isFavorite=${isFavorite}`)
  810. }
  811. }