react-native-queue-player 1.0.2 → 1.0.3

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.
@@ -36,6 +36,13 @@ target_include_directories(alac PUBLIC
36
36
  ${THIRD_PARTY}/alac/codec
37
37
  ${THIRD_PARTY}/alac/addons
38
38
  )
39
+ # Apple's vendored ALAC reference codec carries benign unused-variable noise we
40
+ # don't patch upstream; silence just those two categories for this third-party
41
+ # target so a consumer's release build stays quiet. Our own code keeps -Wall.
42
+ target_compile_options(alac PRIVATE
43
+ -Wno-unused-const-variable
44
+ -Wno-unused-but-set-variable
45
+ )
39
46
 
40
47
  # --- Curve25519 / Ed25519 (retained for libraop / AP1 only) ---
41
48
  add_library(curve25519 STATIC
@@ -70,6 +77,10 @@ add_library(pair_ap STATIC
70
77
  )
71
78
  target_compile_definitions(pair_ap PRIVATE
72
79
  CONFIG_OPENSSL=1
80
+ # pair.c uses OpenSSL's legacy SHA*_Final API, deprecated in OpenSSL 3.0.
81
+ # OPENSSL_SUPPRESS_DEPRECATED is OpenSSL's own opt-in for intentional
82
+ # legacy-API use; the raop + airplay2 targets already set it.
83
+ OPENSSL_SUPPRESS_DEPRECATED
73
84
  )
74
85
  target_include_directories(pair_ap PUBLIC
75
86
  ${PAIR_AP_DIR}
@@ -67,14 +67,17 @@ struct JniSession {
67
67
 
68
68
  // -- Drain thread --
69
69
  pthread_t thread;
70
- bool drain_running;
70
+ // Stop flag: set on the JNI/control thread, polled in the drain thread's
71
+ // loop. Atomic so the stop is a data-race-free, promptly-visible read
72
+ // (the ring buffer has its own mutex; this only gates the loop).
73
+ std::atomic<bool> drain_running{false};
71
74
 
72
75
  // -- Tracking --
73
76
  std::atomic<int64_t> bytes_sent{0}; // total PCM bytes sent to RAOP
74
77
  std::atomic<int64_t> chunks_sent{0}; // total chunks sent
75
78
 
76
79
  JniSession() : raop(nullptr), write_pos(0), read_pos(0),
77
- drain_running(false), thread{} {
80
+ thread{} {
78
81
  memset(ring, 0, sizeof(ring));
79
82
  pthread_mutex_init(&mutex, nullptr);
80
83
  }
@@ -42,6 +42,12 @@ class SleepTimerCore(
42
42
  // defer the pause forever.
43
43
  private var tailExtended = false
44
44
 
45
+ // True while an end-of-track timer is pending, and survives the conversion to
46
+ // a DURATION fade so the real track-end signal (fireAtTrackEnd) knows to fire
47
+ // — the poll can't hit the boundary exactly and never fires at all when the
48
+ // duration is unknown (live/streaming).
49
+ private var originEndOfTrack = false
50
+
45
51
  val isActive: Boolean get() = mode != Mode.INACTIVE
46
52
  val isEndOfTrack: Boolean get() = mode == Mode.END_OF_TRACK
47
53
 
@@ -49,18 +55,33 @@ class SleepTimerCore(
49
55
  mode = Mode.DURATION
50
56
  deadlineEpochMs = nowMs + (seconds * 1000).toLong()
51
57
  tailExtended = false
58
+ originEndOfTrack = false
52
59
  }
53
60
 
54
61
  fun armEndOfTrack() {
55
62
  mode = Mode.END_OF_TRACK
56
63
  deadlineEpochMs = null
57
64
  tailExtended = false
65
+ originEndOfTrack = true
58
66
  }
59
67
 
60
68
  fun clear() {
61
69
  mode = Mode.INACTIVE
62
70
  deadlineEpochMs = null
63
71
  tailExtended = false
72
+ originEndOfTrack = false
73
+ }
74
+
75
+ /**
76
+ * Fire the pending end-of-track timer at the current track's real end. The
77
+ * owning engine calls this from its Media3 auto-transition signal so the pause
78
+ * lands exactly at the boundary (and fires even when the duration was never
79
+ * known). No-op unless an end-of-track timer is pending.
80
+ */
81
+ fun fireAtTrackEnd(): Tick {
82
+ if (!originEndOfTrack) return Tick.IDLE
83
+ clear()
84
+ return Tick(1.0, pauseNow = true, stateChanged = true)
64
85
  }
65
86
 
66
87
  /** Whole seconds until the fixed deadline, or `null` when inactive / awaiting track end. */
@@ -78,9 +99,13 @@ class SleepTimerCore(
78
99
  val tail = trackRemaining(trackDurationSec, trackPositionSec)
79
100
  if (tail != null && tail <= tailGraceSeconds) {
80
101
  mode = Mode.DURATION
81
- deadlineEpochMs = nowMs + ((tail + 1) * 1000).toLong()
102
+ // Deadline lands AT the track boundary (not 1s past it) so the fade
103
+ // completes at the end; on the local engine the real track-end signal
104
+ // (fireAtTrackEnd) owns the actual pause, and this deadline is the
105
+ // fallback the cast path rides out on the receiver's clock.
106
+ deadlineEpochMs = nowMs + (tail * 1000).toLong()
82
107
  tailExtended = true
83
- return Tick(fadeFraction(tail + 1), pauseNow = false, stateChanged = true)
108
+ return Tick(fadeFraction(tail), pauseNow = false, stateChanged = true)
84
109
  }
85
110
  return Tick.IDLE
86
111
  }
@@ -103,9 +128,7 @@ class SleepTimerCore(
103
128
  return Tick(fadeFraction(tail + 1), pauseNow = false, stateChanged = true)
104
129
  }
105
130
  // Fire.
106
- mode = Mode.INACTIVE
107
- deadlineEpochMs = null
108
- tailExtended = false
131
+ clear()
109
132
  return Tick(1.0, pauseNow = true, stateChanged = true)
110
133
  }
111
134
  }
@@ -2629,6 +2629,14 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2629
2629
  positionSec = maxOf(0L, serviceBinder?.engine?.currentPositionMs ?: 0L) / 1000.0
2630
2630
  }
2631
2631
  val result = sleepTimerCore.tick(System.currentTimeMillis(), durationSec, positionSec)
2632
+ applySleepTimerResult(result)
2633
+ }
2634
+
2635
+ /**
2636
+ * Apply a [SleepTimerCore.Tick] decision — fade, state emit, boundary pause.
2637
+ * Shared by the polling tick and the real track-end signal ([fireEndOfTrackSleepTimer]).
2638
+ */
2639
+ private fun applySleepTimerResult(result: SleepTimerCore.Tick) {
2632
2640
  applySleepTimerFade(result.fadeFraction)
2633
2641
  if (result.stateChanged) emitSleepTimerChanged()
2634
2642
  if (result.pauseNow) {
@@ -2643,6 +2651,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2643
2651
  if (!sleepTimerCore.isActive) stopSleepTimerTick()
2644
2652
  }
2645
2653
 
2654
+ /**
2655
+ * Fire the end-of-track sleep timer at a real auto-transition boundary (the
2656
+ * current track finished and Media3 advanced). Called from the engine's
2657
+ * track-transition callback so the pause lands at the boundary and fires even
2658
+ * when the track duration was never known — no-op unless end-of-track is armed.
2659
+ */
2660
+ private fun fireEndOfTrackSleepTimer() {
2661
+ val result = sleepTimerCore.fireAtTrackEnd()
2662
+ if (result.pauseNow) applySleepTimerResult(result)
2663
+ }
2664
+
2646
2665
  private fun applySleepTimerFade(fraction: Double) {
2647
2666
  // Fade is local-only: the receiver's volume is user/system-owned and
2648
2667
  // `volumeState` tracks the local level, so ramping it on the receiver would
@@ -3599,6 +3618,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3599
3618
  internal fun maybeEmitQueueEnd(playbackState: Int) {
3600
3619
  if (playbackState != Player.STATE_ENDED) return
3601
3620
  if (tracks.isEmpty()) return
3621
+ // End-of-track sleep timer on the LAST track: STATE_ENDED fires no
3622
+ // MEDIA_ITEM_TRANSITION, so cover the final boundary here — matching iOS,
3623
+ // whose per-item handlePlayerItemDidPlayToEndTime fires on the last track
3624
+ // too. Idempotent, so it can't double-fire with the poll / transition path.
3625
+ fireEndOfTrackSleepTimer()
3602
3626
  queueEndListeners.forEach { it() }
3603
3627
  emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
3604
3628
  }
@@ -3622,6 +3646,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3622
3646
  val p = player ?: return
3623
3647
  val translated = translateTransitionReason(reason, p)
3624
3648
 
3649
+ // End-of-track sleep timer: a natural track boundary — auto-advance to the
3650
+ // next track OR a repeat-one loop of the same track (both translate to
3651
+ // AUTO_ADVANCE) — is the real end of the current track, so pause here.
3652
+ // fireAtTrackEnd is idempotent, so a deduped crossfade echo can't double-fire.
3653
+ if (translated == TrackChangeReason.AUTO_ADVANCE) fireEndOfTrackSleepTimer()
3654
+
3625
3655
  // Natural track change → reset error dedup so a new item's
3626
3656
  // identical-coded error fires. Without this, a TIMEOUT on
3627
3657
  // track 2 (after a TIMEOUT on track 1) would be silently
@@ -111,6 +111,8 @@ class SleepTimerCoreTest {
111
111
  assertTrue(converted.stateChanged)
112
112
  assertFalse(t.isEndOfTrack)
113
113
  assertTrue(t.isActive)
114
+ // Deadline lands AT the boundary (tail=50s), not 1s past it.
115
+ assertEquals(2000L + 50_000L, t.deadlineEpochMs)
114
116
  }
115
117
 
116
118
  @Test
@@ -122,4 +124,43 @@ class SleepTimerCoreTest {
122
124
  assertNull(t.deadlineEpochMs)
123
125
  assertNull(t.remainingSeconds(0))
124
126
  }
127
+
128
+ @Test
129
+ fun `end-of-track fires a pause at the track-end signal`() {
130
+ val t = SleepTimerCore()
131
+ t.armEndOfTrack()
132
+ // Fires at the real track-end signal, before any tail conversion and even
133
+ // when the duration was never known (never ticked).
134
+ val fired = t.fireAtTrackEnd()
135
+ assertTrue(fired.pauseNow)
136
+ assertTrue(fired.stateChanged)
137
+ assertFalse(t.isActive)
138
+ }
139
+
140
+ @Test
141
+ fun `end-of-track signal fires after tail conversion`() {
142
+ val t = SleepTimerCore()
143
+ t.armEndOfTrack()
144
+ t.tick(1000, trackDurationSec = 200.0, trackPositionSec = 150.0) // converts to fade
145
+ val fired = t.fireAtTrackEnd()
146
+ assertTrue(fired.pauseNow)
147
+ assertFalse(t.isActive)
148
+ }
149
+
150
+ @Test
151
+ fun `fireAtTrackEnd is idempotent`() {
152
+ val t = SleepTimerCore()
153
+ t.armEndOfTrack()
154
+ t.fireAtTrackEnd()
155
+ assertFalse(t.fireAtTrackEnd().pauseNow)
156
+ }
157
+
158
+ @Test
159
+ fun `fireAtTrackEnd ignores a duration timer`() {
160
+ val t = SleepTimerCore()
161
+ t.armDuration(seconds = 60.0, nowMs = 0)
162
+ val fired = t.fireAtTrackEnd()
163
+ assertFalse(fired.pauseNow)
164
+ assertTrue(t.isActive)
165
+ }
125
166
  }
@@ -4,8 +4,7 @@ import AVFoundation
4
4
  /// to the AVAudioSession `Mode` + `CategoryOptions` used during
5
5
  /// `AudioSession.activate()`.
6
6
  ///
7
- /// Pure function; no instance state. Mirrors `PlaybackErrorMapping`
8
- /// + `InterruptionEventMapping`.
7
+ /// Pure function; no instance state. Mirrors `PlaybackErrorMapping`.
9
8
  ///
10
9
  /// `audioContentType == .speech` triggers two effects:
11
10
  /// 1. AVAudioSession mode flips to `.spokenAudio` (per Apple HIG —
@@ -36,6 +36,11 @@ struct SleepTimerCore {
36
36
  // Guards the tail rule to a single track so a stalled/looping position can't
37
37
  // defer the pause forever.
38
38
  private var tailExtended = false
39
+ // True while an end-of-track timer is pending, and survives the conversion to
40
+ // a `.duration` fade so the real track-end signal (`fireAtTrackEnd`) knows to
41
+ // fire — the poll can't hit the boundary exactly and never fires at all when
42
+ // the duration is unknown (live/streaming).
43
+ private var originEndOfTrack = false
39
44
 
40
45
  let fadeSeconds: Double
41
46
  let tailGraceSeconds: Double
@@ -52,18 +57,31 @@ struct SleepTimerCore {
52
57
  mode = .duration
53
58
  deadlineEpochMs = nowMs + Int64(seconds * 1000)
54
59
  tailExtended = false
60
+ originEndOfTrack = false
55
61
  }
56
62
 
57
63
  mutating func armEndOfTrack() {
58
64
  mode = .endOfTrack
59
65
  deadlineEpochMs = nil
60
66
  tailExtended = false
67
+ originEndOfTrack = true
61
68
  }
62
69
 
63
70
  mutating func clear() {
64
71
  mode = .inactive
65
72
  deadlineEpochMs = nil
66
73
  tailExtended = false
74
+ originEndOfTrack = false
75
+ }
76
+
77
+ /// Fire the pending end-of-track timer at the current track's real end. The
78
+ /// owning player calls this from its track-end signal so the pause lands
79
+ /// exactly at the boundary (and fires even when the track duration was never
80
+ /// known). No-op unless an end-of-track timer is pending.
81
+ mutating func fireAtTrackEnd() -> Tick {
82
+ guard originEndOfTrack else { return .idle }
83
+ clear()
84
+ return Tick(fadeFraction: 1.0, pauseNow: true, stateChanged: true)
67
85
  }
68
86
 
69
87
  /// Whole seconds until the fixed deadline, or `nil` when inactive / awaiting
@@ -83,9 +101,13 @@ struct SleepTimerCore {
83
101
  // then let the duration branch ride it out + fire.
84
102
  if let tail = trackRemaining(trackDurationSec, trackPositionSec), tail <= tailGraceSeconds {
85
103
  mode = .duration
86
- deadlineEpochMs = nowMs + Int64((tail + 1) * 1000)
104
+ // Deadline lands AT the track boundary (not 1s past it) so the fade
105
+ // completes at the end; on local playback the real track-end signal
106
+ // (fireAtTrackEnd) owns the actual pause, and this deadline is the
107
+ // fallback the cast path rides out on the receiver's clock.
108
+ deadlineEpochMs = nowMs + Int64(tail * 1000)
87
109
  tailExtended = true
88
- return Tick(fadeFraction: fadeFraction(tail + 1), pauseNow: false, stateChanged: true)
110
+ return Tick(fadeFraction: fadeFraction(tail), pauseNow: false, stateChanged: true)
89
111
  }
90
112
  return .idle
91
113
 
@@ -106,9 +128,7 @@ struct SleepTimerCore {
106
128
  return Tick(fadeFraction: fadeFraction(tail + 1), pauseNow: false, stateChanged: true)
107
129
  }
108
130
  // Fire.
109
- mode = .inactive
110
- deadlineEpochMs = nil
111
- tailExtended = false
131
+ clear()
112
132
  return Tick(fadeFraction: 1.0, pauseNow: true, stateChanged: true)
113
133
  }
114
134
  }
@@ -39,9 +39,15 @@ final class FFTProcessorTests: XCTestCase {
39
39
 
40
40
  func testDoesNotEmitUntilFftSizeSamplesAccumulate() throws {
41
41
  var calls = 0
42
+ // Fulfilled by the emit itself so the wait tracks the actual main-queue
43
+ // callback rather than a fixed delay that can lose the race under load.
44
+ let emitted = expectation(description: "emit landed")
42
45
  let proc = try FFTProcessor(
43
46
  fftSize: 512, intervalMs: 1, includeSamples: false,
44
- ) { _, _, _ in calls += 1 }
47
+ ) { _, _, _ in
48
+ calls += 1
49
+ emitted.fulfill()
50
+ }
45
51
  let half = [Float](repeating: 0, count: 256)
46
52
  half.withUnsafeBufferPointer { p in
47
53
  proc.ingest(samples: p.baseAddress!, frameCount: 256, sampleRate: 44100)
@@ -51,10 +57,7 @@ final class FFTProcessorTests: XCTestCase {
51
57
  rest.withUnsafeBufferPointer { p in
52
58
  proc.ingest(samples: p.baseAddress!, frameCount: 256, sampleRate: 44100)
53
59
  }
54
- // The callback hops to main; spin the run loop to give it a chance.
55
- let exp = expectation(description: "emit landed")
56
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() }
57
- wait(for: [exp], timeout: 1.0)
60
+ wait(for: [emitted], timeout: 1.0)
58
61
  XCTAssertEqual(calls, 1)
59
62
  }
60
63
 
@@ -85,6 +85,8 @@ final class SleepTimerCoreTests: XCTestCase {
85
85
  XCTAssertTrue(converted.stateChanged)
86
86
  XCTAssertFalse(t.isEndOfTrack)
87
87
  XCTAssertTrue(t.isActive)
88
+ // Deadline lands AT the boundary (tail=50s), not 1s past it.
89
+ XCTAssertEqual(t.deadlineEpochMs, 2000 + 50_000)
88
90
  }
89
91
 
90
92
  func testClearResetsToInactive() {
@@ -95,4 +97,41 @@ final class SleepTimerCoreTests: XCTestCase {
95
97
  XCTAssertNil(t.deadlineEpochMs)
96
98
  XCTAssertNil(t.remainingSeconds(nowMs: 0))
97
99
  }
100
+
101
+ func testEndOfTrackFiresPauseAtTrackEndSignal() {
102
+ var t = SleepTimerCore()
103
+ t.armEndOfTrack()
104
+ // Fires at the real track-end signal, before any tail conversion and even
105
+ // when the duration was never known (never ticked).
106
+ let fired = t.fireAtTrackEnd()
107
+ XCTAssertTrue(fired.pauseNow)
108
+ XCTAssertTrue(fired.stateChanged)
109
+ XCTAssertFalse(t.isActive)
110
+ }
111
+
112
+ func testEndOfTrackSignalFiresAfterTailConversion() {
113
+ var t = SleepTimerCore()
114
+ t.armEndOfTrack()
115
+ _ = t.tick(nowMs: 1000, trackDurationSec: 200, trackPositionSec: 150) // converts to fade
116
+ // The end-of-track origin survives the conversion, so the boundary signal
117
+ // still owns the pause.
118
+ let fired = t.fireAtTrackEnd()
119
+ XCTAssertTrue(fired.pauseNow)
120
+ XCTAssertFalse(t.isActive)
121
+ }
122
+
123
+ func testFireAtTrackEndIsIdempotent() {
124
+ var t = SleepTimerCore()
125
+ t.armEndOfTrack()
126
+ _ = t.fireAtTrackEnd()
127
+ XCTAssertFalse(t.fireAtTrackEnd().pauseNow)
128
+ }
129
+
130
+ func testFireAtTrackEndIgnoresDurationTimer() {
131
+ var t = SleepTimerCore()
132
+ t.armDuration(seconds: 60, nowMs: 0)
133
+ let fired = t.fireAtTrackEnd()
134
+ XCTAssertFalse(fired.pauseNow)
135
+ XCTAssertTrue(t.isActive)
136
+ }
98
137
  }
@@ -424,6 +424,9 @@ class TrackPlayer: HybridTrackPlayerSpec {
424
424
  // first track needs the same initial-buffer leniency.
425
425
  private var currentItemObserver: NSKeyValueObservation?
426
426
  private var currentItemStatusObserver: NSKeyValueObservation?
427
+ // Duration can resolve a beat AFTER `.status` reaches `.readyToPlay`; observe
428
+ // it so the lock-screen duration lands even in that race.
429
+ private var currentItemDurationObserver: NSKeyValueObservation?
427
430
  private var gaplessFlipArmed: Bool = true
428
431
 
429
432
  /// Set to true by the `AVPlayerItemDidPlayToEndTime` notification
@@ -666,24 +669,11 @@ class TrackPlayer: HybridTrackPlayerSpec {
666
669
  break
667
670
  }
668
671
 
669
- // Surface the interruption kind as a typed non-fatal
670
- // `PlaybackError` so JS consumers can branch on the resume
671
- // hint without inspecting a boolean. Wire literals + messages
672
- // come from `InterruptionEventMapping` (single source of
673
- // truth for the three nativeDomain strings).
674
- let domain = InterruptionEventMapping.nativeDomain(for: kind)
675
- let message = InterruptionEventMapping.message(for: kind)
676
- let err = PlaybackError(
677
- code: .unknown,
678
- message: message,
679
- fatal: false,
680
- nativeCode: 0,
681
- nativeDomain: domain,
682
- nativeMessage: message,
683
- queueItemId: "",
684
- url: ""
685
- )
686
- self.errorListeners.forEach { $0(err) }
672
+ // An interruption is a normal lifecycle signal (call / Siri / a route
673
+ // handoff), NOT a playback error surfacing it on the `onError` stream
674
+ // makes consumers render an error banner for a routine pause. The pause
675
+ // is already reported via the `.paused` state stamped with
676
+ // `reason = .interruption`, which is the correct, non-error signal.
687
677
  }
688
678
 
689
679
  self.audioSession.onRouteChange = { [weak self] kind in
@@ -992,6 +982,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
992
982
  private func attachStatusObserverIfArmed() {
993
983
  currentItemStatusObserver?.invalidate()
994
984
  currentItemStatusObserver = nil
985
+ currentItemDurationObserver?.invalidate()
986
+ currentItemDurationObserver = nil
995
987
  tearDownBufferObservers()
996
988
  guard let item = self.player?.currentItem else {
997
989
  // No current item (empty queue / torn down) — settle to empty.
@@ -1034,6 +1026,14 @@ class TrackPlayer: HybridTrackPlayerSpec {
1034
1026
  }
1035
1027
  }
1036
1028
  self.dispatchItemStatus(item)
1029
+ currentItemDurationObserver = item.observe(
1030
+ \.duration, options: [.new]
1031
+ ) { [weak self] _, _ in
1032
+ guard let self else { return }
1033
+ DispatchQueue.main.async {
1034
+ self.nowPlayingInfo.refreshPositionAndRate()
1035
+ }
1036
+ }
1037
1037
  installBufferObservers(on: item)
1038
1038
  }
1039
1039
 
@@ -1086,6 +1086,11 @@ class TrackPlayer: HybridTrackPlayerSpec {
1086
1086
  // idempotent on items that already carry a tap, so a double-
1087
1087
  // fire is harmless.
1088
1088
  AudioTapProvider.shared.refreshActiveItemMixes()
1089
+ // Duration is only reliably readable once the item is `.readyToPlay`.
1090
+ // Re-publish now-playing so the lock-screen scrubber gets a duration on
1091
+ // first load + auto-advance (not just after a manual skip, which is the
1092
+ // only path that otherwise forces a state change that re-publishes it).
1093
+ self.nowPlayingInfo.refreshPositionAndRate()
1089
1094
  case .failed:
1090
1095
  // Pause synchronously BEFORE surfacing the typed error. Without
1091
1096
  // this, AVQueuePlayer treats `.failed` as end-of-item and chain-
@@ -1191,6 +1196,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
1191
1196
  currentItemObserver = nil
1192
1197
  currentItemStatusObserver?.invalidate()
1193
1198
  currentItemStatusObserver = nil
1199
+ currentItemDurationObserver?.invalidate()
1200
+ currentItemDurationObserver = nil
1194
1201
  tearDownBufferObservers()
1195
1202
  }
1196
1203
 
@@ -2591,6 +2598,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
2591
2598
  nowMs: Self.nowEpochMs(),
2592
2599
  trackDurationSec: durationSec,
2593
2600
  trackPositionSec: positionSec)
2601
+ applySleepTimerResult(result)
2602
+ }
2603
+
2604
+ /// Apply a `SleepTimerCore.Tick` decision to the engine — fade, state emit,
2605
+ /// and the boundary pause. Shared by the polling tick and the real track-end
2606
+ /// signal (`handlePlayerItemDidPlayToEndTime`).
2607
+ private func applySleepTimerResult(_ result: SleepTimerCore.Tick) {
2594
2608
  applySleepTimerFade(result.fadeFraction)
2595
2609
  if result.stateChanged { emitSleepTimerChanged() }
2596
2610
  if result.pauseNow {
@@ -3766,6 +3780,12 @@ class TrackPlayer: HybridTrackPlayerSpec {
3766
3780
  @objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
3767
3781
  DispatchQueue.main.async { [weak self] in
3768
3782
  guard let self else { return }
3783
+ // End-of-track sleep timer: pause at the current track's real end —
3784
+ // before the repeat-track rewind or the auto-advance — so the pause
3785
+ // lands exactly at the boundary and fires even when the track duration
3786
+ // was never known. Under repeat-one this pauses at the first natural end.
3787
+ let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
3788
+ if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
3769
3789
  // Branch on repeat-track FIRST, separately from the
3770
3790
  // currentItem === item check. The notification dispatches
3771
3791
  // async to main but AVQueuePlayer
@@ -3796,8 +3816,10 @@ class TrackPlayer: HybridTrackPlayerSpec {
3796
3816
  player.currentItem === item {
3797
3817
  item.seek(to: .zero, completionHandler: nil)
3798
3818
  // Replay through the engine so the user's playback speed is
3799
- // restored (a raw AVPlayer.play() resets rate to 1.0).
3800
- self.engine?.play()
3819
+ // restored (a raw AVPlayer.play() resets rate to 1.0) — unless the
3820
+ // end-of-track sleep timer just fired, in which case we pause at
3821
+ // this natural end instead of looping.
3822
+ if !sleepFired.pauseNow { self.engine?.play() }
3801
3823
  }
3802
3824
  }
3803
3825
  // If the rewind window was lost (player chain-advanced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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",
@@ -1,51 +0,0 @@
1
- import Foundation
2
-
3
- /// Maps `AudioSession.InterruptionKind` to the lib-standardized
4
- /// `nativeDomain` string + a stable human-readable message used by
5
- /// `TrackPlayer.wireAudioSession()` when firing the corresponding
6
- /// non-fatal `PlaybackError` through `errorListeners`.
7
- ///
8
- /// Pure function; no instance state. Mirrors `PlaybackErrorMapping`.
9
- ///
10
- /// Three `nativeDomain` strings — all share the
11
- /// `AUDIO_SESSION_INTERRUPTION_` prefix so JS consumers can branch
12
- /// on `nativeDomain.startsWith(...)` and treat any
13
- /// audio-session-interruption variant as a single category when they
14
- /// don't care about the resume-hint sub-state. `.endedShouldNotResume`
15
- /// maps to the bare `..._ENDED` (no `_SHOULD_NOT_RESUME` suffix) so
16
- /// consumers that ignore the resume-hint variant default to the
17
- /// "stop" semantics.
18
- enum InterruptionEventMapping {
19
-
20
- /// Shared prefix for the three interruption `nativeDomain` strings.
21
- static let nativeDomainPrefix = "AUDIO_SESSION_INTERRUPTION_"
22
-
23
- /// `nativeDomain` for the supplied interruption kind.
24
- static func nativeDomain(for kind: AudioSession.InterruptionKind) -> String {
25
- switch kind {
26
- case .began:
27
- return nativeDomainPrefix + "BEGAN"
28
- case .endedShouldResume:
29
- return nativeDomainPrefix + "ENDED_SHOULD_RESUME"
30
- case .endedShouldNotResume:
31
- return nativeDomainPrefix + "ENDED"
32
- }
33
- }
34
-
35
- /// Human-readable message for the supplied interruption kind.
36
- /// Same string is fired as both `message` and `nativeMessage` on
37
- /// the resulting `PlaybackError` — there's no lib-mapped vs native
38
- /// distinction at this layer (session-scope event, not track-
39
- /// scope error).
40
- static func message(for kind: AudioSession.InterruptionKind) -> String {
41
- switch kind {
42
- case .began:
43
- return "Audio session interruption began"
44
- case .endedShouldResume:
45
- return "Audio session interruption ended; system suggests resume"
46
- case .endedShouldNotResume:
47
- return "Audio session interruption ended"
48
- }
49
- }
50
-
51
- }
@@ -1,91 +0,0 @@
1
- import XCTest
2
- @testable import QueuePlayer
3
-
4
- /// `InterruptionEventMapping` is a pure-function namespace mapping
5
- /// `AudioSession.InterruptionKind` to the lib-standardized
6
- /// `nativeDomain` string + a stable human-readable message + the
7
- /// non-fatal `PlaybackError` that `TrackPlayer.wireAudioSession()`
8
- /// fires through `errorListeners`.
9
- final class InterruptionEventMappingTests: XCTestCase {
10
-
11
- // MARK: - nativeDomain
12
-
13
- func testNativeDomainBeganIsBeganLiteral() {
14
- XCTAssertEqual(
15
- InterruptionEventMapping.nativeDomain(for: .began),
16
- "AUDIO_SESSION_INTERRUPTION_BEGAN"
17
- )
18
- }
19
-
20
- func testNativeDomainEndedShouldResumeIsResumeHintLiteral() {
21
- XCTAssertEqual(
22
- InterruptionEventMapping.nativeDomain(for: .endedShouldResume),
23
- "AUDIO_SESSION_INTERRUPTION_ENDED_SHOULD_RESUME"
24
- )
25
- }
26
-
27
- func testNativeDomainEndedShouldNotResumeIsBareEndedLiteral() {
28
- XCTAssertEqual(
29
- InterruptionEventMapping.nativeDomain(for: .endedShouldNotResume),
30
- "AUDIO_SESSION_INTERRUPTION_ENDED"
31
- )
32
- }
33
-
34
- func testAllInterruptionDomainsShareTheBranchablePrefix() {
35
- let kinds: [AudioSession.InterruptionKind] = [
36
- .began, .endedShouldResume, .endedShouldNotResume,
37
- ]
38
- for kind in kinds {
39
- let domain = InterruptionEventMapping.nativeDomain(for: kind)
40
- XCTAssertTrue(
41
- domain.hasPrefix(InterruptionEventMapping.nativeDomainPrefix),
42
- "domain for \(kind) must use the consumer-branchable prefix; got \(domain)"
43
- )
44
- }
45
- }
46
-
47
- func testTheThreeDomainsAreExactlyTheSetOfWireLiterals() {
48
- let domains: Set<String> = [
49
- InterruptionEventMapping.nativeDomain(for: .began),
50
- InterruptionEventMapping.nativeDomain(for: .endedShouldResume),
51
- InterruptionEventMapping.nativeDomain(for: .endedShouldNotResume),
52
- ]
53
- XCTAssertEqual(domains, [
54
- "AUDIO_SESSION_INTERRUPTION_BEGAN",
55
- "AUDIO_SESSION_INTERRUPTION_ENDED_SHOULD_RESUME",
56
- "AUDIO_SESSION_INTERRUPTION_ENDED",
57
- ], "wire-protocol literals must not drift; rename = breaking change for JS consumers")
58
- }
59
-
60
- // MARK: - message
61
-
62
- func testMessagesAreNonEmpty() {
63
- let kinds: [AudioSession.InterruptionKind] = [
64
- .began, .endedShouldResume, .endedShouldNotResume,
65
- ]
66
- for kind in kinds {
67
- XCTAssertFalse(
68
- InterruptionEventMapping.message(for: kind).isEmpty,
69
- "message for \(kind) must be non-empty"
70
- )
71
- }
72
- }
73
-
74
- func testMessagesAreDistinctPerKind() {
75
- let began = InterruptionEventMapping.message(for: .began)
76
- let resume = InterruptionEventMapping.message(for: .endedShouldResume)
77
- let ended = InterruptionEventMapping.message(for: .endedShouldNotResume)
78
- XCTAssertNotEqual(began, resume)
79
- XCTAssertNotEqual(began, ended)
80
- XCTAssertNotEqual(resume, ended)
81
- }
82
-
83
- // PlaybackError construction lives inline at the call site in
84
- // `TrackPlayer.wireAudioSession()` — Swift cxx-interop excludes
85
- // internal helpers that return C++-bridged types like
86
- // `PlaybackError` from the `@testable import` swiftmodule export,
87
- // so the construction can't be invoked from an XCTest target. The
88
- // wire literals + messages are pinned by the tests above; full-
89
- // path coverage lives in the Maestro `interruption-handling`
90
- // suite.
91
- }