react-native-queue-player 1.0.0 → 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/build.gradle +5 -0
- package/android/consumer-rules.pro +14 -0
- 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/AudioPipelineRenderersFactory.kt +8 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +37 -25
- package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchAudioProcessor.kt +27 -19
- 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/AudioPipelineRenderersFactoryTest.kt +14 -13
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +64 -5
- 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 +6 -2
- package/ios/InterruptionEventMapping.swift +0 -51
- package/ios/Tests/InterruptionEventMappingTests.swift +0 -91
package/android/build.gradle
CHANGED
|
@@ -71,6 +71,11 @@ android {
|
|
|
71
71
|
targetSdkVersion getExtOrDefault("targetSdkVersion")
|
|
72
72
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
73
73
|
|
|
74
|
+
// Shipped in the AAR and applied to the consumer's R8 run so a consuming
|
|
75
|
+
// app's release build needs no manual rules for this library's transitive
|
|
76
|
+
// dependencies (e.g. Ktor's java.lang.management reference on Android).
|
|
77
|
+
consumerProguardFiles "consumer-rules.pro"
|
|
78
|
+
|
|
74
79
|
externalNativeBuild {
|
|
75
80
|
cmake {
|
|
76
81
|
cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# React Native Queue Player — consumer R8 / ProGuard rules.
|
|
2
|
+
#
|
|
3
|
+
# Packaged into the published AAR via `consumerProguardFiles` and applied
|
|
4
|
+
# automatically to a consuming app's R8 run, so consumers need no manual rules
|
|
5
|
+
# for this library's transitive dependencies.
|
|
6
|
+
|
|
7
|
+
# The embedded Ktor CIO HTTP server (the Android AirPlay 2 receiver) ships
|
|
8
|
+
# io.ktor.util.debug.IntellijIdeaDebugDetector, which references
|
|
9
|
+
# java.lang.management.ManagementFactory and RuntimeMXBean to detect an attached
|
|
10
|
+
# IntelliJ debugger. Those JMX classes are JVM-only and absent on Android, so a
|
|
11
|
+
# consumer's R8 release build (full mode treats missing classes as errors) fails
|
|
12
|
+
# on the missing references. The detector is debug tooling that never executes
|
|
13
|
+
# at runtime on Android; ignore the missing classes rather than fail the build.
|
|
14
|
+
-dontwarn java.lang.management.**
|
|
@@ -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
|
}
|
package/android/src/main/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactory.kt
CHANGED
|
@@ -23,12 +23,14 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
|
|
|
23
23
|
* interleave their `flush` + buffer state. The CrossfadeEngine builds two
|
|
24
24
|
* factories (one per leg); each receives its own chain.
|
|
25
25
|
*
|
|
26
|
-
* Float
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* Float output is deliberately NOT enabled. With Media3 float output, a hi-res
|
|
27
|
+
* (24-bit+) source produces a float sink, and [DefaultAudioSink.configure] then
|
|
28
|
+
* DROPS the entire custom [AudioProcessorChain] — its float branch omits the
|
|
29
|
+
* chain because the built-in Sonic outputs 16-bit — silently disabling playback
|
|
30
|
+
* speed, pitch-correction, ReplayGain, and the visualizer on hi-res content.
|
|
31
|
+
* Keeping the sink at 16-bit (Media3 downconverts hi-res) keeps the chain in the
|
|
32
|
+
* pipeline for every source; [ReplayGainAudioProcessor] and
|
|
33
|
+
* [SignalsmithStretchAudioProcessor] both process 16-bit PCM directly.
|
|
32
34
|
*/
|
|
33
35
|
@UnstableApi
|
|
34
36
|
internal class AudioPipelineRenderersFactory(
|
|
@@ -36,10 +38,6 @@ internal class AudioPipelineRenderersFactory(
|
|
|
36
38
|
private val audioProcessorChain: AudioProcessorChain,
|
|
37
39
|
) : DefaultRenderersFactory(context) {
|
|
38
40
|
|
|
39
|
-
init {
|
|
40
|
-
setEnableAudioFloatOutput(true)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
41
|
override fun buildAudioSink(
|
|
44
42
|
context: Context,
|
|
45
43
|
enableFloatOutput: Boolean,
|
|
@@ -5,23 +5,22 @@ import androidx.media3.common.audio.AudioProcessor
|
|
|
5
5
|
import androidx.media3.common.audio.BaseAudioProcessor
|
|
6
6
|
import androidx.media3.common.util.UnstableApi
|
|
7
7
|
import java.nio.ByteBuffer
|
|
8
|
+
import kotlin.math.roundToInt
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Standalone Media3 [AudioProcessor] that applies a per-track linear
|
|
11
|
-
* gain to a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* arrival.
|
|
12
|
+
* gain to a PCM stream. No knowledge of the engine, the EQ stage, or the
|
|
13
|
+
* visualizer pipeline — purely "given a PCM buffer, multiply by
|
|
14
|
+
* [linearGain]". The owning engine pushes a new gain via [setLinearGain]
|
|
15
|
+
* from its `Player.Listener` callbacks; `onMediaItemTransition` resets to
|
|
16
|
+
* unity until the next track's tags arrive.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* and
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* non-Float32 no-op limitation in NOTES.md §25.
|
|
18
|
+
* Handles both 16-bit and float PCM. The sink runs at 16-bit (see
|
|
19
|
+
* [AudioPipelineRenderersFactory] — float output would drop the whole
|
|
20
|
+
* chain on hi-res sources), so a 16-bit block is scaled in float precision
|
|
21
|
+
* and narrowed back with a round + full-scale clamp; a float block is
|
|
22
|
+
* scaled in place. The gain is already clip-capped against the track peak
|
|
23
|
+
* (`ReplayGainGain`), so the clamp only guards sentinel/missing-peak tags.
|
|
25
24
|
*/
|
|
26
25
|
@UnstableApi
|
|
27
26
|
internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
|
|
@@ -37,11 +36,11 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
|
|
|
37
36
|
private var linearGain: Float = 1.0f
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* Input PCM encoding captured at [onConfigure]. Volatile for the same
|
|
40
|
+
* render-thread / main-thread split as [linearGain].
|
|
42
41
|
*/
|
|
43
42
|
@Volatile
|
|
44
|
-
private var
|
|
43
|
+
private var encoding: Int = C.ENCODING_INVALID
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* Push a new linear gain. `1.0f` disables the processor's per-sample
|
|
@@ -52,7 +51,7 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
|
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
override fun onConfigure(input: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
|
|
55
|
-
|
|
54
|
+
encoding = input.encoding
|
|
56
55
|
// Pass-through: this stage doesn't change rate, channel count, or
|
|
57
56
|
// encoding. Returning [AudioProcessor.AudioFormat.NOT_SET] would
|
|
58
57
|
// tell Media3 the processor consumes the stream and drops the
|
|
@@ -61,23 +60,36 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
|
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
override fun isActive(): Boolean =
|
|
64
|
-
super.isActive() && linearGain != 1.0f &&
|
|
63
|
+
super.isActive() && linearGain != 1.0f &&
|
|
64
|
+
(encoding == C.ENCODING_PCM_FLOAT || encoding == C.ENCODING_PCM_16BIT)
|
|
65
65
|
|
|
66
66
|
override fun queueInput(input: ByteBuffer) {
|
|
67
67
|
val gain = linearGain
|
|
68
68
|
val byteCount = input.remaining()
|
|
69
69
|
if (byteCount == 0) return
|
|
70
70
|
val out = replaceOutputBuffer(byteCount)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
if (encoding == C.ENCODING_PCM_FLOAT) {
|
|
72
|
+
val inF = input.asFloatBuffer()
|
|
73
|
+
val outF = out.asFloatBuffer()
|
|
74
|
+
while (inF.hasRemaining()) {
|
|
75
|
+
outF.put(inF.get() * gain)
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// 16-bit: scale in float precision, narrow back with round + clamp.
|
|
79
|
+
// The gain is peak-capped upstream, so the clamp only guards
|
|
80
|
+
// sentinel/missing-peak tags against wraparound.
|
|
81
|
+
val inS = input.asShortBuffer()
|
|
82
|
+
val outS = out.asShortBuffer()
|
|
83
|
+
while (inS.hasRemaining()) {
|
|
84
|
+
val scaled = inS.get() * gain
|
|
85
|
+
outS.put(scaled.roundToInt().coerceIn(-32768, 32767).toShort())
|
|
86
|
+
}
|
|
75
87
|
}
|
|
76
88
|
input.position(input.limit())
|
|
77
89
|
// Advance the byte-level position so [getOutput] hands back a
|
|
78
|
-
// buffer with the produced bytes between position and limit
|
|
79
|
-
//
|
|
80
|
-
//
|
|
90
|
+
// buffer with the produced bytes between position and limit — the
|
|
91
|
+
// typed-view writes mutate the underlying bytes but don't move the
|
|
92
|
+
// parent ByteBuffer's position.
|
|
81
93
|
out.position(byteCount)
|
|
82
94
|
out.flip()
|
|
83
95
|
}
|
package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchAudioProcessor.kt
CHANGED
|
@@ -56,11 +56,12 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
56
56
|
private var floatIn: ByteBuffer? = null
|
|
57
57
|
private var floatOut: ByteBuffer? = null
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// True once a stretched block has been produced, so the engine's tail
|
|
60
|
+
// latency awaits a drain. [onQueueEndOfStream] drains it exactly once and
|
|
61
|
+
// clears this — the native `outputLatency()` reports the fixed algorithmic
|
|
62
|
+
// latency (not the live buffer fill), so re-draining re-emits the same tail
|
|
63
|
+
// frames (an audible stutter) and never ends the stream.
|
|
64
|
+
private var pendingDrain = false
|
|
64
65
|
|
|
65
66
|
// Fractional frame remainder so per-block outFrames average to inFrames/speed.
|
|
66
67
|
private var frameCarry: Double = 0.0
|
|
@@ -73,13 +74,16 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
73
74
|
fun isStretching(): Boolean = isActive && speed != 1f
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* Map a playout duration back to media duration
|
|
77
|
-
* `
|
|
78
|
-
* the
|
|
77
|
+
* Map a playout duration back to media duration. For a pure time-stretch,
|
|
78
|
+
* `s` seconds of media play per playout second at speed `s`, so media =
|
|
79
|
+
* playout * speed — computed from the live speed rather than a cumulative
|
|
80
|
+
* input/output byte ratio, which mis-maps while the engine's latency is
|
|
81
|
+
* drained (the drained tail inflates output-vs-input) and freezes the
|
|
82
|
+
* position clock on a speed change.
|
|
79
83
|
*/
|
|
80
84
|
fun getMediaDuration(playoutDuration: Long): Long =
|
|
81
|
-
if (
|
|
82
|
-
else Util.scaleLargeTimestamp(playoutDuration,
|
|
85
|
+
if (speed == 1f) playoutDuration
|
|
86
|
+
else Util.scaleLargeTimestamp(playoutDuration, (speed * 1_000_000f).toLong(), 1_000_000L)
|
|
83
87
|
|
|
84
88
|
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
|
|
85
89
|
encoding = inputAudioFormat.encoding
|
|
@@ -87,8 +91,11 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
87
91
|
sampleRateHz = inputAudioFormat.sampleRate
|
|
88
92
|
bytesPerFrame = channelCount * if (encoding == C.ENCODING_PCM_FLOAT) 4 else 2
|
|
89
93
|
// A reconfigure invalidates any engine built for the previous format;
|
|
90
|
-
// the next stretching queueInput lazily rebuilds it.
|
|
94
|
+
// the next stretching queueInput lazily rebuilds it. Clear the drain flag
|
|
95
|
+
// so the invariant holds locally, independent of the flush that Media3
|
|
96
|
+
// issues after a (re)configure.
|
|
91
97
|
releaseNative()
|
|
98
|
+
pendingDrain = false
|
|
92
99
|
// Pass-through format — the stretch changes duration, not rate/channels/
|
|
93
100
|
// encoding. Returning NOT_SET would drop the sink.
|
|
94
101
|
return inputAudioFormat
|
|
@@ -148,13 +155,17 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
input.position(input.limit())
|
|
151
|
-
|
|
152
|
-
outputBytes += outFrames.toLong() * bytesPerFrame
|
|
158
|
+
pendingDrain = true
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
override fun onQueueEndOfStream() {
|
|
156
162
|
val stretch = nativeStretch ?: return
|
|
157
|
-
|
|
163
|
+
// Drain the engine's tail latency exactly once per stretched span. Media3
|
|
164
|
+
// can call this repeatedly around a reconfigure; `outputLatency()` reports
|
|
165
|
+
// the fixed algorithmic latency, so re-draining re-emits the same tail
|
|
166
|
+
// frames (stutter) and the stream never ends, stalling a speed change.
|
|
167
|
+
if (!pendingDrain || speed == 1f) return
|
|
168
|
+
pendingDrain = false
|
|
158
169
|
val latency = stretch.outputLatency()
|
|
159
170
|
if (latency <= 0) return
|
|
160
171
|
if (encoding == C.ENCODING_PCM_FLOAT) {
|
|
@@ -176,14 +187,12 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
176
187
|
out.position(latency * bytesPerFrame)
|
|
177
188
|
out.flip()
|
|
178
189
|
}
|
|
179
|
-
outputBytes += latency.toLong() * bytesPerFrame
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
|
|
183
193
|
nativeStretch?.reset()
|
|
184
194
|
frameCarry = 0.0
|
|
185
|
-
|
|
186
|
-
outputBytes = 0L
|
|
195
|
+
pendingDrain = false
|
|
187
196
|
}
|
|
188
197
|
|
|
189
198
|
override fun onReset() {
|
|
@@ -191,8 +200,7 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
|
|
|
191
200
|
floatIn = null
|
|
192
201
|
floatOut = null
|
|
193
202
|
frameCarry = 0.0
|
|
194
|
-
|
|
195
|
-
outputBytes = 0L
|
|
203
|
+
pendingDrain = false
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
private fun ensureNative(): SignalsmithStretchNative {
|
|
@@ -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
|
package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt
CHANGED
|
@@ -3,8 +3,8 @@ package com.margelo.nitro.queueplayer
|
|
|
3
3
|
import androidx.media3.common.util.UnstableApi
|
|
4
4
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
|
5
5
|
import androidx.test.core.app.ApplicationProvider
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
6
7
|
import org.junit.Assert.assertNotNull
|
|
7
|
-
import org.junit.Assert.assertTrue
|
|
8
8
|
import org.junit.Test
|
|
9
9
|
import org.junit.runner.RunWith
|
|
10
10
|
import org.robolectric.RobolectricTestRunner
|
|
@@ -13,8 +13,8 @@ import java.lang.reflect.Field
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Unit coverage for [AudioPipelineRenderersFactory] construction — it wraps a
|
|
16
|
-
* caller-supplied [androidx.media3.common.audio.AudioProcessorChain] and
|
|
17
|
-
* the produced sink
|
|
16
|
+
* caller-supplied [androidx.media3.common.audio.AudioProcessorChain] and leaves
|
|
17
|
+
* the produced sink at 16-bit (float output off). The engines drive end-to-end exercise of
|
|
18
18
|
* `buildAudioSink` via `ExoPlayer.Builder.build()` in
|
|
19
19
|
* [GaplessEngineLifecycleTest] / [CrossfadeEngineLifecycleTest]; `buildAudioSink`
|
|
20
20
|
* itself is `protected` on [DefaultRenderersFactory] and cannot be called
|
|
@@ -36,21 +36,22 @@ class AudioPipelineRenderersFactoryTest {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
@Test
|
|
39
|
-
fun `factory
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
39
|
+
fun `factory leaves audio float output off so the chain survives on hi-res sources`() {
|
|
40
|
+
// Float output would make a hi-res (24-bit+) source produce a float sink,
|
|
41
|
+
// and DefaultAudioSink.configure()'s float branch DROPS the custom
|
|
42
|
+
// AudioProcessorChain — silently disabling playback speed, pitch-correction,
|
|
43
|
+
// ReplayGain, and the visualizer on hi-res content. Keeping the sink at
|
|
44
|
+
// 16-bit (Media3 downconverts hi-res) keeps the chain in the pipeline for
|
|
45
|
+
// every source. Pin that the factory leaves float output off (the
|
|
46
|
+
// DefaultRenderersFactory default) via reflection — no public accessor
|
|
47
|
+
// exists, but the field name is stable across Media3 1.x.
|
|
47
48
|
val factory =
|
|
48
49
|
AudioPipelineRenderersFactory(context, PitchAwareAudioProcessorChain(emptyArray()))
|
|
49
50
|
val field: Field =
|
|
50
51
|
DefaultRenderersFactory::class.java.getDeclaredField("enableFloatOutput")
|
|
51
52
|
field.isAccessible = true
|
|
52
|
-
|
|
53
|
-
"AudioPipelineRenderersFactory must enable float output",
|
|
53
|
+
assertFalse(
|
|
54
|
+
"AudioPipelineRenderersFactory must NOT enable float output (it drops the chain on hi-res)",
|
|
54
55
|
field.getBoolean(factory),
|
|
55
56
|
)
|
|
56
57
|
}
|
|
@@ -14,10 +14,11 @@ import java.nio.ByteBuffer
|
|
|
14
14
|
import java.nio.ByteOrder
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Unit coverage for [ReplayGainAudioProcessor] — the per-sample
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Unit coverage for [ReplayGainAudioProcessor] — the per-sample gain
|
|
18
|
+
* stage Media3 wires into the AudioSink chain. Exercises the
|
|
19
|
+
* [AudioProcessor] contract: configure, isActive gating, in-place multiply
|
|
20
|
+
* on both 16-bit and float PCM, the 16-bit full-scale clamp, and the
|
|
21
|
+
* encoding gate.
|
|
21
22
|
*/
|
|
22
23
|
@RunWith(RobolectricTestRunner::class)
|
|
23
24
|
@Config(sdk = [34])
|
|
@@ -43,10 +44,18 @@ class ReplayGainAudioProcessorTest {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
@Test
|
|
46
|
-
fun `isActive is
|
|
47
|
+
fun `isActive is true with non-unity gain on a 16-bit input`() {
|
|
47
48
|
val proc = ReplayGainAudioProcessor()
|
|
48
49
|
proc.configure(int16Format(48000, 2))
|
|
49
50
|
proc.setLinearGain(0.5f)
|
|
51
|
+
assertTrue(proc.isActive)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Test
|
|
55
|
+
fun `isActive is false on an unsupported encoding`() {
|
|
56
|
+
val proc = ReplayGainAudioProcessor()
|
|
57
|
+
proc.configure(AudioProcessor.AudioFormat(48000, 2, C.ENCODING_PCM_24BIT))
|
|
58
|
+
proc.setLinearGain(0.5f)
|
|
50
59
|
assertFalse(proc.isActive)
|
|
51
60
|
}
|
|
52
61
|
|
|
@@ -82,6 +91,39 @@ class ReplayGainAudioProcessorTest {
|
|
|
82
91
|
assertEquals(0.6f, output[2], 1e-6f)
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
@Test
|
|
95
|
+
fun `queueInput scales 16-bit samples by the linear gain`() {
|
|
96
|
+
val proc = ReplayGainAudioProcessor()
|
|
97
|
+
proc.configure(int16Format(48000, 1))
|
|
98
|
+
proc.setLinearGain(0.5f)
|
|
99
|
+
|
|
100
|
+
val input = shortBuffer(shortArrayOf(10000, -8000, 4, 0, -10000))
|
|
101
|
+
proc.queueInput(input)
|
|
102
|
+
|
|
103
|
+
val output = readShorts(proc.output, 5)
|
|
104
|
+
assertEquals(5000, output[0].toInt())
|
|
105
|
+
assertEquals(-4000, output[1].toInt())
|
|
106
|
+
assertEquals(2, output[2].toInt())
|
|
107
|
+
assertEquals(0, output[3].toInt())
|
|
108
|
+
assertEquals(-5000, output[4].toInt())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun `queueInput clamps a 16-bit boost that exceeds full scale`() {
|
|
113
|
+
val proc = ReplayGainAudioProcessor()
|
|
114
|
+
proc.configure(int16Format(44100, 1))
|
|
115
|
+
proc.setLinearGain(2.0f)
|
|
116
|
+
|
|
117
|
+
// 20000*2 = 40000 > 32767 and -20000*2 = -40000 < -32768 both clamp.
|
|
118
|
+
val input = shortBuffer(shortArrayOf(20000, -20000, 1000))
|
|
119
|
+
proc.queueInput(input)
|
|
120
|
+
|
|
121
|
+
val output = readShorts(proc.output, 3)
|
|
122
|
+
assertEquals(32767, output[0].toInt())
|
|
123
|
+
assertEquals(-32768, output[1].toInt())
|
|
124
|
+
assertEquals(2000, output[2].toInt())
|
|
125
|
+
}
|
|
126
|
+
|
|
85
127
|
@Test
|
|
86
128
|
fun `queueInput consumes the input buffer`() {
|
|
87
129
|
val proc = ReplayGainAudioProcessor()
|
|
@@ -137,5 +179,22 @@ class ReplayGainAudioProcessorTest {
|
|
|
137
179
|
return out
|
|
138
180
|
}
|
|
139
181
|
|
|
182
|
+
private fun shortBuffer(values: ShortArray): ByteBuffer {
|
|
183
|
+
val buffer = ByteBuffer
|
|
184
|
+
.allocateDirect(values.size * Short.SIZE_BYTES)
|
|
185
|
+
.order(ByteOrder.nativeOrder())
|
|
186
|
+
val asShort = buffer.asShortBuffer()
|
|
187
|
+
for (v in values) asShort.put(v)
|
|
188
|
+
buffer.position(0).limit(values.size * Short.SIZE_BYTES)
|
|
189
|
+
return buffer
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun readShorts(buffer: ByteBuffer, count: Int): ShortArray {
|
|
193
|
+
val out = ShortArray(count)
|
|
194
|
+
val asShort = buffer.asShortBuffer()
|
|
195
|
+
for (i in 0 until count) out[i] = asShort.get()
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
140
199
|
// endregion
|
|
141
200
|
}
|
|
@@ -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",
|
|
@@ -39,7 +39,11 @@
|
|
|
39
39
|
"nitrogen": "nitrogen && bash scripts/strip-nitrogen-unused-let.sh",
|
|
40
40
|
"typecheck": "tsc",
|
|
41
41
|
"test": "jest",
|
|
42
|
-
"test:coverage": "jest --coverage"
|
|
42
|
+
"test:coverage": "jest --coverage",
|
|
43
|
+
"release:patch": "node scripts/release.mjs patch",
|
|
44
|
+
"release:minor": "node scripts/release.mjs minor",
|
|
45
|
+
"release:major": "node scripts/release.mjs major",
|
|
46
|
+
"publish:npm": "npm publish --access public"
|
|
43
47
|
},
|
|
44
48
|
"keywords": [
|
|
45
49
|
"react-native",
|
|
@@ -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
|
-
}
|