react-native-queue-player 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -277,11 +277,17 @@ final class GaplessEngine: PlaybackEngine {
277
277
  reapplyPitchAlgorithm()
278
278
  }
279
279
 
280
- func setRepeatMode(_: RepeatMode) {
281
- // AVQueuePlayer doesn't have a native repeat mode equivalent
282
- // to ExoPlayer; TrackPlayer's QueueSkipArithmetic handles the
283
- // repeat semantics by rebuilding the queue at the appropriate
284
- // moment. No work to do at the engine layer.
280
+ func setRepeatMode(_ mode: RepeatMode) {
281
+ // AVQueuePlayer has no native repeat mode. Under repeat-one, hold the
282
+ // finished item at its end (`actionAtItemEnd = .none`) so the player
283
+ // does not auto-advance `TrackPlayer.handlePlayerItemDidPlayToEndTime`
284
+ // then deterministically rewinds it to zero and replays it. `.off` /
285
+ // `.queue` advance + wrap are driven by TrackPlayer's queue rebuild, so
286
+ // they keep `.advance`. Switching mode only affects future end-of-item
287
+ // behaviour; an item already parked at its end (repeat-one + the
288
+ // end-of-track sleep-timer pause) is not retroactively advanced — a
289
+ // resume or skip recovers it.
290
+ player.actionAtItemEnd = (mode == .track) ? .none : .advance
285
291
  }
286
292
 
287
293
  func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {
@@ -30,6 +30,32 @@ final class GaplessEngineLifecycleTests: XCTestCase {
30
30
  XCTAssertNil(engine.delegate)
31
31
  }
32
32
 
33
+ // MARK: - Repeat mode
34
+
35
+ /// Repeat-one holds the current item at its end
36
+ /// (`actionAtItemEnd = .none`) so `TrackPlayer`'s end-of-track handler
37
+ /// can deterministically rewind + replay it; off / queue keep the
38
+ /// default `.advance`. Without the hold, the AVQueuePlayer advances
39
+ /// into the next enqueued item before the async rewind runs, so the
40
+ /// track skips forward instead of repeating.
41
+ func testRepeatTrackHoldsItemAtEndWhileOthersAdvance() {
42
+ let engine = GaplessEngine()
43
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
44
+
45
+ engine.setRepeatMode(.track)
46
+ XCTAssertEqual(engine.player.actionAtItemEnd, .none)
47
+
48
+ engine.setRepeatMode(.queue)
49
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
50
+
51
+ engine.setRepeatMode(.off)
52
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
53
+
54
+ // Re-entering repeat-one re-applies the hold.
55
+ engine.setRepeatMode(.track)
56
+ XCTAssertEqual(engine.player.actionAtItemEnd, .none)
57
+ }
58
+
33
59
  // MARK: - Queue-mutation surface
34
60
 
35
61
  /// `setItems` installs the slice from `startIndex` onward; items
@@ -3952,10 +3952,12 @@ class TrackPlayer: HybridTrackPlayerSpec {
3952
3952
  /// pre-wrote.
3953
3953
  ///
3954
3954
  /// Repeat-track handling: when `repeatModeState == .track`, seek
3955
- /// the just-finished item back to zero + replay. The notification
3956
- /// fires BEFORE AVQueuePlayer auto-advances (per Apple docs), so
3957
- /// we get the chance to rewind without flashing through the next
3958
- /// item. We do NOT set `didPlayToEndPending` in this branch —
3955
+ /// the just-finished item back to zero + replay. The gapless engine
3956
+ /// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
3957
+ /// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
3958
+ /// player does not advance and `player.currentItem` is still the
3959
+ /// finished item when this async block runs — the rewind is
3960
+ /// deterministic. We do NOT set `didPlayToEndPending` in this branch —
3959
3961
  /// the seek-to-zero produces no `\.currentItem` change, so there
3960
3962
  /// is no auto-advance to gate.
3961
3963
  @objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
@@ -3968,30 +3970,21 @@ class TrackPlayer: HybridTrackPlayerSpec {
3968
3970
  let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
3969
3971
  if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
3970
3972
  // Branch on repeat-track FIRST, separately from the
3971
- // currentItem === item check. The notification dispatches
3972
- // async to main but AVQueuePlayer
3973
- // runs `actionAtItemEnd = .advance` synchronously on its own
3974
- // queue — under load, the player can have already advanced
3975
- // by the time our async block runs. Falling through to the
3976
- // auto-advance branch in repeat-track mode would incorrectly
3977
- // arm `didPlayToEndPending`; the resync would then run on
3978
- // the unintended-advance currentItem and clobber the user's
3979
- // expected repeat-track stay-on-current.
3973
+ // currentItem === item check, and never arm `didPlayToEndPending`
3974
+ // here in repeat-track mode there is no auto-advance to gate.
3980
3975
  if self.repeatModeState == .track {
3981
- // Repeat-track: rewind the just-finished item + replay.
3982
- // Apple's docs note the notification fires BEFORE
3983
- // actionAtItemEnd is honoured, so the seek-to-zero +
3984
- // play normally beats the auto-advance.
3976
+ // Repeat-track: rewind the just-finished item + replay. The
3977
+ // gapless engine holds the item via `actionAtItemEnd = .none`
3978
+ // under `.track`, so the player does not advance and
3979
+ // `currentItem` is still the finished item here.
3985
3980
  if let player = self.player {
3986
3981
  // Repeat-one loop = a new milestone playthrough of the same track.
3987
3982
  // The gapless engine fires no track-change / delegate here, so
3988
3983
  // dispatchTrackChange's reset is skipped — reset directly, and
3989
- // UNCONDITIONALLY for the gapless engine (self.player non-nil), not
3990
- // only when the rewind race below is won: under load this async block
3991
- // can run AFTER AVQueuePlayer already chain-advanced, in which case
3992
- // the seek is skipped but the track still loops, so milestones must
3993
- // re-arm regardless. (CrossfadeEngine has self.player == nil here and
3994
- // resets via engineDidPlayItemToEnd instead — no double reset.)
3984
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), so
3985
+ // milestones re-arm for the new playthrough. (CrossfadeEngine has
3986
+ // self.player == nil here and resets via engineDidPlayItemToEnd
3987
+ // instead no double reset.)
3995
3988
  self.milestoneTracker.reset()
3996
3989
  if let item = notification.object as? AVPlayerItem,
3997
3990
  player.currentItem === item {
@@ -4003,15 +3996,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
4003
3996
  if !sleepFired.pauseNow { self.engine?.play() }
4004
3997
  }
4005
3998
  }
4006
- // If the rewind window was lost (player chain-advanced
4007
- // before our async block ran), accept that the
4008
- // KVO fire will re-sync currentTrackIndex via the next
4009
- // currentItem changebut DO NOT arm `didPlayToEndPending`
4010
- // here, because in repeat-track mode the auto-advance
4011
- // shouldn't have happened. The resync via KVO without
4012
- // didPlayToEndPending stays correct because user
4013
- // mutations pre-write the index; nothing overwrites the
4014
- // shadow index incorrectly.
3999
+ // Defense in depth: if `currentItem` is somehow not the finished
4000
+ // item (e.g. a mode change raced this fire before `.none` was
4001
+ // applied), skip the rewind and let the `\.currentItem` KVO
4002
+ // re-sync `currentTrackIndex`WITHOUT arming
4003
+ // `didPlayToEndPending`, since repeat-track should not advance.
4004
+ // User mutations pre-write the index, so the shadow index stays
4005
+ // correct.
4015
4006
  return
4016
4007
  }
4017
4008
  // Repeat-off / repeat-queue: arm the resync gate so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/index.d.ts",