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.
@@ -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
- 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
  }
@@ -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-output is opted in at construction via [setEnableAudioFloatOutput]:
27
- * Media3's [DefaultRenderersFactory] defaults that flag to `false`, which would
28
- * propagate as `enableFloatOutput = false` into [buildAudioSink] and produce an
29
- * Int16 PCM sink. [ReplayGainAudioProcessor.isActive] and
30
- * [SignalsmithStretchAudioProcessor.isActive] gate on `ENCODING_PCM_FLOAT`, so
31
- * without the explicit opt-in they would silently run as pass-throughs.
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 Float32 PCM stream. No knowledge of the engine, the EQ
12
- * stage, or the visualizer pipeline — purely "given a Float32 buffer,
13
- * multiply by [linearGain]". The owning engine pushes a new gain via
14
- * [setLinearGain] from its `Player.Listener.onMetadata` callback;
15
- * `onMediaItemTransition` resets to unity until the next metadata
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
- * Float32-only by design. [AudioPipelineRenderersFactory] opts the
19
- * factory into `setEnableAudioFloatOutput(true)` at construction so
20
- * Media3 propagates `enableFloatOutput = true` into `buildAudioSink`,
21
- * and the resulting `DefaultAudioSink` produces `ENCODING_PCM_FLOAT`.
22
- * Sources decoded to Int16 PCM transit through this processor
23
- * untouched via [isActive] returning `false`, matching the documented
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
- * `true` when [onConfigure] saw `ENCODING_PCM_FLOAT`. Volatile for
41
- * the same render-thread / main-thread split as [linearGain].
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 inputIsFloat: Boolean = false
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
- inputIsFloat = (input.encoding == C.ENCODING_PCM_FLOAT)
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 && inputIsFloat
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
- val inF = input.asFloatBuffer()
72
- val outF = out.asFloatBuffer()
73
- while (inF.hasRemaining()) {
74
- outF.put(inF.get() * gain)
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. The
79
- // float-view writes mutate the underlying bytes but don't move
80
- // the parent ByteBuffer's position.
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
  }
@@ -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
- // Input (media) vs output (playout) byte tallies since the last flush, for
60
- // the chain's getMediaDuration position mapping. Accumulated only while
61
- // stretching (the pass-through span never delegates to this processor).
62
- private var inputBytes: Long = 0L
63
- private var outputBytes: Long = 0L
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, mirroring Sonic's model:
77
- * `playout * inputBytes / outputBytes`. Returns the playout unchanged until
78
- * the first stretched block has been produced.
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 (outputBytes == 0L) playoutDuration
82
- else Util.scaleLargeTimestamp(playoutDuration, inputBytes, outputBytes)
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
- inputBytes += inFrames.toLong() * bytesPerFrame
152
- outputBytes += outFrames.toLong() * bytesPerFrame
158
+ pendingDrain = true
153
159
  }
154
160
 
155
161
  override fun onQueueEndOfStream() {
156
162
  val stretch = nativeStretch ?: return
157
- if (speed == 1f) return // not stretching nothing buffered to drain
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
- inputBytes = 0L
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
- inputBytes = 0L
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
- 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
@@ -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 opts
17
- * the produced sink into float output. The engines drive end-to-end exercise of
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 enables audio float output so the float processors see Float32 PCM`() {
40
- // [DefaultRenderersFactory.enableAudioFloatOutput] defaults to false;
41
- // without an explicit opt-in Media3 propagates `enableFloatOutput = false`
42
- // into [buildAudioSink], which makes the sink emit Int16 PCM and silently
43
- // disables the float-gated processors (ReplayGain + the music stretcher,
44
- // whose isActive gate on ENCODING_PCM_FLOAT). The factory's init block flips
45
- // the flag to true; pin that by reading the private field via reflection —
46
- // no public accessor exists, but the field name is stable across Media3 1.x.
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
- assertTrue(
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
- * Float32 gain stage Media3 wires into the AudioSink chain. Exercises
19
- * the [AudioProcessor] contract: configure, isActive gating, in-place
20
- * multiply, encoding gate.
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 false on a non-Float32 input regardless of gain`() {
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 —
@@ -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.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
- }