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.
- package/android/src/main/cpp/CMakeLists.txt +11 -0
- package/android/src/main/cpp/airplay_jni.cpp +5 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +28 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +30 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SleepTimerCoreTest.kt +41 -0
- package/ios/AudioCategoryMapping.swift +1 -2
- package/ios/SleepTimerCore.swift +25 -5
- package/ios/Tests/FFTProcessorTests.swift +8 -5
- package/ios/Tests/SleepTimerCoreTests.swift +39 -0
- package/ios/TrackPlayer.swift +42 -20
- package/package.json +1 -1
- package/ios/InterruptionEventMapping.swift +0 -51
- package/ios/Tests/InterruptionEventMappingTests.swift +0 -91
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 —
|
package/ios/SleepTimerCore.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|