Selaa lähdekoodia

fix(player): guard playback coordinator state

onecold 4 kuukautta sitten
vanhempi
sitoutus
4c0e5dbfc7

+ 7 - 1
entry/src/main/ets/controller/PlaybackCoordinator.ets

@@ -36,8 +36,14 @@ export class PlaybackCoordinator {
     if (!this.runtime || queue.length <= 0) {
       return
     }
+    const previousSnapshot = this.stateBridge.getSnapshot()
     this.stateBridge.replaceQueue(queue, startIndex)
-    await this.runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source)
+    try {
+      await this.runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source)
+    } catch (error) {
+      this.stateBridge.restoreSnapshot(previousSnapshot)
+      throw error
+    }
   }
 
   public async playSong(song: VideoItem, source: string): Promise<void> {

+ 12 - 0
entry/src/main/ets/controller/PlaybackStateBridge.ets

@@ -77,6 +77,18 @@ export class PlaybackStateBridge {
     AppStorage.setOrCreate('isPlaying', isPlaying)
   }
 
+  public restoreSnapshot(snapshot: PlaybackSnapshot): void {
+    const safeQueue = snapshot.queue.slice()
+    this.snapshot = {
+      ...snapshot,
+      queue: safeQueue
+    }
+    AppStorage.setOrCreate('currentSong', snapshot.currentSong)
+    AppStorage.setOrCreate('currentQueue', safeQueue)
+    AppStorage.setOrCreate('currentQueueIndex', snapshot.currentIndex)
+    AppStorage.setOrCreate('isPlaying', snapshot.isPlaying)
+  }
+
   public getSnapshot(): PlaybackSnapshot {
     return {
       ...this.snapshot,

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

@@ -45,6 +45,131 @@ export default function playbackCoordinatorTest() {
       coordinator.clearRuntime()
     })
 
+    it('playSongPropagatesStartIndexAndSourceAndUpdatesBridge', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const song = new VideoItem('Song C', '3', '/music/c.flac', 0, 0, '2026-04-02')
+      const runtimeCalls: string[] = []
+
+      bridge.reset()
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (_queue, startIndex, source) => {
+          runtimeCalls.push(`${startIndex}:${source}`)
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      await coordinator.playSong(song, 'playlist-detail')
+
+      const snapshot = bridge.getSnapshot()
+      expect(runtimeCalls.join(',')).assertEqual('0:playlist-detail')
+      expect(snapshot.currentIndex).assertEqual(0)
+      expect(snapshot.currentSong?.filePath ?? '').assertEqual('/music/c.flac')
+      coordinator.clearRuntime()
+    })
+
+    it('clearRuntimeOnlyRemovesMatchingInstance', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      let playOrPauseCount = 0
+
+      const runtimeA: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          playOrPauseCount++
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      const runtimeB: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      coordinator.clearRuntime()
+      coordinator.setRuntime(runtimeA)
+      coordinator.clearRuntime(runtimeB)
+
+      await coordinator.playOrPause()
+      expect(playOrPauseCount).assertEqual(1)
+
+      coordinator.clearRuntime(runtimeA)
+      await coordinator.playOrPause()
+      expect(playOrPauseCount).assertEqual(1)
+    })
+
+    it('playQueueWithoutRuntimeKeepsBridgeState', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const initialSong = new VideoItem('Initial', 'init', '/music/initial.flac', 0, 0, '2026-04-02')
+
+      bridge.reset()
+      bridge.replaceQueue([initialSong], 0)
+      const before = bridge.getSnapshot()
+
+      coordinator.clearRuntime()
+      await coordinator.playQueue(
+        [new VideoItem('Song D', '4', '/music/d.flac', 0, 0, '2026-04-02')],
+        0,
+        'charts'
+      )
+
+      const after = bridge.getSnapshot()
+      expect(after.currentIndex).assertEqual(before.currentIndex)
+      expect(after.currentSong?.filePath ?? '').assertEqual(before.currentSong?.filePath ?? '')
+      expect(after.queue.length).assertEqual(before.queue.length)
+    })
+
+    it('playQueueRollsBackOnRuntimeError', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const previousSong = new VideoItem('Previous', 'prev', '/music/prev.flac', 0, 0, '2026-04-02')
+
+      bridge.reset()
+      bridge.replaceQueue([previousSong], 0)
+      const snapshotBefore = bridge.getSnapshot()
+
+      let threwError = false
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async () => {
+          throw new Error('boom')
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      try {
+        await coordinator.playQueue(
+          [
+            new VideoItem('Song E', '5', '/music/e.flac', 0, 0, '2026-04-02'),
+            new VideoItem('Song F', '6', '/music/f.flac', 0, 0, '2026-04-02')
+          ],
+          0,
+          'playlist'
+        )
+      } catch (error) {
+        threwError = true
+      }
+
+      expect(threwError).assertEqual(true)
+      const snapshotAfter = bridge.getSnapshot()
+      expect(snapshotAfter.queue.length).assertEqual(snapshotBefore.queue.length)
+      expect(snapshotAfter.currentIndex).assertEqual(snapshotBefore.currentIndex)
+      expect(snapshotAfter.currentSong?.filePath ?? '').assertEqual(snapshotBefore.currentSong?.filePath ?? '')
+      coordinator.clearRuntime()
+    })
+
     it('playSongWrapsSingleSongQueue', 0, async () => {
       const coordinator = PlaybackCoordinator.getInstance()
       let queueLength = 0