react-native-queue-player 1.0.2 → 1.0.4

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.
Files changed (99) hide show
  1. package/android/src/main/cpp/CMakeLists.txt +11 -0
  2. package/android/src/main/cpp/airplay_jni.cpp +5 -2
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +1 -1
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/CarConnectionState.kt +46 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +1 -3
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +2 -4
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +7 -9
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +1 -2
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +5 -6
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +8 -1
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +1 -1
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +1 -1
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +29 -6
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +51 -27
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +2 -2
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +2 -5
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +1 -4
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +1 -2
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +3 -7
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +1 -2
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -3
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +2 -3
  25. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +1 -2
  26. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +0 -2
  27. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +3 -8
  28. package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +2 -4
  29. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +2 -3
  30. package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +1 -3
  31. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +1 -2
  32. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  33. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +2 -2
  34. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +76 -7
  35. package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +1 -3
  36. package/android/src/test/java/com/margelo/nitro/queueplayer/SleepTimerCoreTest.kt +41 -0
  37. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +1 -2
  38. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -4
  39. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +1 -2
  40. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +2 -3
  41. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +1 -3
  42. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +2 -5
  43. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -2
  44. package/ios/AVPlayerItemQueueItemId.swift +2 -2
  45. package/ios/AVQueueBuilder.swift +12 -14
  46. package/ios/AudioCategoryMapping.swift +1 -2
  47. package/ios/CarPlayCoordinator.swift +28 -0
  48. package/ios/CarPlaySceneDelegate.swift +2 -0
  49. package/ios/CrossfadeEngine.swift +4 -5
  50. package/ios/InputGuards.swift +3 -3
  51. package/ios/LookaheadCache.swift +4 -6
  52. package/ios/LookaheadCachePrefetcher.swift +1 -1
  53. package/ios/MilestoneTracker.swift +1 -2
  54. package/ios/PlaybackErrorMapping.swift +6 -7
  55. package/ios/PlayerStateDerivation.swift +1 -2
  56. package/ios/ProgressEmissionGate.swift +1 -1
  57. package/ios/QueueMutationArithmetic.swift +2 -2
  58. package/ios/QueueSkipArithmetic.swift +8 -12
  59. package/ios/Siri/PlayMediaIntentHandler.swift +1 -5
  60. package/ios/SleepTimerCore.swift +26 -6
  61. package/ios/Tests/AudioTapProviderReplayGainTests.swift +5 -9
  62. package/ios/Tests/CarPlayBridgeTests.swift +2 -6
  63. package/ios/Tests/CarPlayBrowseBuilderTests.swift +1 -2
  64. package/ios/Tests/CarPlayCoordinatorTests.swift +35 -0
  65. package/ios/Tests/EQProcessorTests.swift +1 -4
  66. package/ios/Tests/EQTapTests.swift +1 -2
  67. package/ios/Tests/EqualizerAudioMixProviderTests.swift +3 -4
  68. package/ios/Tests/EqualizerHybridTests.swift +1 -3
  69. package/ios/Tests/FFTProcessorTests.swift +9 -8
  70. package/ios/Tests/GaplessEngineLifecycleTests.swift +0 -2
  71. package/ios/Tests/LookaheadCacheEvictionTests.swift +1 -4
  72. package/ios/Tests/MetadataReaderTests.swift +0 -2
  73. package/ios/Tests/MilestoneTrackerTests.swift +1 -3
  74. package/ios/Tests/NowPlayingFormatExtractorTests.swift +3 -4
  75. package/ios/Tests/PlaybackErrorMappingTests.swift +0 -3
  76. package/ios/Tests/PlaybackModeStateMachineTests.swift +5 -9
  77. package/ios/Tests/PlayerStateDerivationTests.swift +1 -2
  78. package/ios/Tests/ProgressEmissionGateTests.swift +1 -3
  79. package/ios/Tests/QueueMutationArithmeticTests.swift +0 -3
  80. package/ios/Tests/RemoteCommandsTests.swift +2 -5
  81. package/ios/Tests/ReplayGainExtractorTests.swift +0 -5
  82. package/ios/Tests/SkipCapabilityTests.swift +4 -7
  83. package/ios/Tests/SleepTimerCoreTests.swift +39 -0
  84. package/ios/Tests/VoiceDonationTests.swift +4 -4
  85. package/ios/TrackPlayer.swift +47 -22
  86. package/lib/typescript/TrackPlayer.nitro.d.ts +17 -0
  87. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  88. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  89. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +4 -0
  91. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
  92. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  93. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
  94. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  95. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  96. package/package.json +1 -1
  97. package/src/TrackPlayer.nitro.ts +18 -0
  98. package/ios/InterruptionEventMapping.swift +0 -51
  99. package/ios/Tests/InterruptionEventMappingTests.swift +0 -91
@@ -36,6 +36,13 @@ target_include_directories(alac PUBLIC
36
36
  ${THIRD_PARTY}/alac/codec
37
37
  ${THIRD_PARTY}/alac/addons
38
38
  )
39
+ # Apple's vendored ALAC reference codec carries benign unused-variable noise we
40
+ # don't patch upstream; silence just those two categories for this third-party
41
+ # target so a consumer's release build stays quiet. Our own code keeps -Wall.
42
+ target_compile_options(alac PRIVATE
43
+ -Wno-unused-const-variable
44
+ -Wno-unused-but-set-variable
45
+ )
39
46
 
40
47
  # --- Curve25519 / Ed25519 (retained for libraop / AP1 only) ---
41
48
  add_library(curve25519 STATIC
@@ -70,6 +77,10 @@ add_library(pair_ap STATIC
70
77
  )
71
78
  target_compile_definitions(pair_ap PRIVATE
72
79
  CONFIG_OPENSSL=1
80
+ # pair.c uses OpenSSL's legacy SHA*_Final API, deprecated in OpenSSL 3.0.
81
+ # OPENSSL_SUPPRESS_DEPRECATED is OpenSSL's own opt-in for intentional
82
+ # legacy-API use; the raop + airplay2 targets already set it.
83
+ OPENSSL_SUPPRESS_DEPRECATED
73
84
  )
74
85
  target_include_directories(pair_ap PUBLIC
75
86
  ${PAIR_AP_DIR}
@@ -67,14 +67,17 @@ struct JniSession {
67
67
 
68
68
  // -- Drain thread --
69
69
  pthread_t thread;
70
- 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
  }
@@ -9,7 +9,7 @@ import androidx.media3.common.Player
9
9
  *
10
10
  * Extracted from [TrackPlayer.computeBufferState] so the discriminator
11
11
  * between an initial `BUFFERING` load and a mid-playback `STALLED`
12
- * rebuffer is unit-testable without a live ExoPlayer, matching the
12
+ * rebuffer stands alone without a live ExoPlayer, matching the
13
13
  * [TransportStateTranslator] pattern.
14
14
  */
15
15
  internal object BufferStateTranslator {
@@ -0,0 +1,46 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import androidx.annotation.VisibleForTesting
4
+ import java.util.concurrent.atomic.AtomicInteger
5
+
6
+ /**
7
+ * Process-wide count of attached headless (automotive) controllers, backing
8
+ * the `isCarConnected()` getter. Holds the same "a car is connected" state
9
+ * the `onCarConnect` / `onCarDisconnect` events transition between, so a
10
+ * consumer that missed the connect event — because it fired before the
11
+ * consumer registered its handler in this process — can still gate car-only
12
+ * work (e.g. browse-snapshot pushes) on the current connection.
13
+ *
14
+ * The count is process-scoped rather than tied to a [PlaybackService]
15
+ * instance so it stays correct across a service restart while a controller
16
+ * remains attached: the new instance's `onConnect` and the dying instance's
17
+ * late `onConnect`/`onDisconnected` all mutate this single source of truth, so
18
+ * the getter can't momentarily read disconnected mid-handoff. It is
19
+ * floor-guarded so a stray disconnect can't drive it negative, and process
20
+ * death clears it (a fresh process starts disconnected), which is correct.
21
+ *
22
+ * Only [PlaybackServiceCallback] mutates this — at the same headless
23
+ * connect / disconnect points that drive the events. This count is separate
24
+ * from that callback's per-instance counter, which stays per-service so the
25
+ * events keep their per-session first/last edge semantics.
26
+ */
27
+ internal object CarConnectionState {
28
+ private val count = AtomicInteger(0)
29
+
30
+ fun isConnected(): Boolean = count.get() > 0
31
+
32
+ /** A headless controller attached. */
33
+ fun retain() {
34
+ count.incrementAndGet()
35
+ }
36
+
37
+ /** A headless controller detached; never drops below zero. */
38
+ fun release() {
39
+ count.updateAndGet { if (it > 0) it - 1 else 0 }
40
+ }
41
+
42
+ @VisibleForTesting
43
+ fun reset() {
44
+ count.set(0)
45
+ }
46
+ }
@@ -25,9 +25,7 @@ import com.margelo.nitro.core.Promise
25
25
  * The JS-facing methods that mutate state return `Promise<Unit>`
26
26
  * via `Promise.async` per the spec, but each delegates to a
27
27
  * synchronous `*Internal` method that owns the actual mutation.
28
- * Unit tests target the synchronous core; the async wrapper is a
29
- * JS-interface adapter and is exercised at integration via demo
30
- * Maestro.
28
+ * The async wrapper is a thin JS-interface adapter.
31
29
  *
32
30
  * Listener registries follow the same shape as other event
33
31
  * listeners on this lib: stable numeric IDs returned via the
@@ -141,8 +141,7 @@ public abstract class HeadlessJsMediaService :
141
141
  /**
142
142
  * The host's [ReactHost]. Returns `null` when the host
143
143
  * `Application` does not implement [ReactApplication] — production
144
- * Expo / RN consumers always do; the null branch covers the
145
- * Robolectric / unit-test path where the application is a stub.
144
+ * Expo / RN consumers always do.
146
145
  */
147
146
  protected val reactHost: ReactHost?
148
147
  get() = (application as? ReactApplication)?.reactHost
@@ -151,8 +150,7 @@ public abstract class HeadlessJsMediaService :
151
150
  * The live [ReactContext] from the consumer app's [ReactHost], or
152
151
  * `null` when no context has been created yet (the service kicks
153
152
  * one off via [createReactContextAndScheduleTask] in that case) OR
154
- * when the host application isn't a [ReactApplication] at all (e.g.
155
- * unit-test stubs).
153
+ * when the host application isn't a [ReactApplication] at all.
156
154
  */
157
155
  protected val reactContext: ReactContext?
158
156
  get() = reactHost?.currentReactContext
@@ -285,9 +285,8 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
285
285
  * cache state.
286
286
  *
287
287
  * `runCatching` swallows the "Method myLooper in android.os.Looper
288
- * not mocked" exception that fires under plain-JUnit (the writer's
289
- * pure-orchestration tests deliberately avoid Robolectric to stay
290
- * fast); production reads the looper normally.
288
+ * not mocked" exception that fires on a JVM host without the Android
289
+ * framework present; production reads the looper normally.
291
290
  */
292
291
  private fun requireMainThread() {
293
292
  val onMain = runCatching {
@@ -429,12 +428,11 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
429
428
  // Errors (OutOfMemoryError etc.) propagate — those are
430
429
  // not transient and shouldn't be hidden.
431
430
  //
432
- // Log via runCatching: android.util.Log is not stubbed in
433
- // plain-JUnit test runs (the writer's pure-orchestration
434
- // tests deliberately avoid Robolectric), so a Log.w call
435
- // would throw "Method w in android.util.Log not mocked"
436
- // and terminate the prefetch loop. Production reads this
437
- // log normally; tests get a silent no-op.
431
+ // Log via runCatching: on a JVM host without the Android
432
+ // framework present, android.util.Log is not stubbed, so a
433
+ // Log.w call would throw "Method w in android.util.Log not
434
+ // mocked" and terminate the prefetch loop. Production reads
435
+ // this log normally.
438
436
  runCatching { Log.w(TAG, "Lookahead prefetch failed for ${track.url}: ${e.message}") }
439
437
  }
440
438
 
@@ -2,8 +2,7 @@ package com.margelo.nitro.queueplayer
2
2
 
3
3
  /**
4
4
  * Tracks fixed playback-progress milestones (25/50/75/90%) for the
5
- * current track playthrough. Pure + deterministic (no Media3), so it
6
- * unit-tests directly.
5
+ * current track playthrough. Pure + deterministic (no Media3).
7
6
  *
8
7
  * A threshold emits only when forward playback crosses it: [seek] moves
9
8
  * the baseline WITHOUT emitting (so thresholds a seek jumps over are
@@ -150,8 +150,8 @@ class PlaybackService : HeadlessJsMediaService() {
150
150
  * [PendingIntentBuffer] TTL: ~3 s for bridge boot + ~17 s of
151
151
  * headroom for the consumer's network calls before the controller
152
152
  * surfaces "no results" and the user retries. The two platforms'
153
- * cold-start windows are deliberately the same so the manual
154
- * playbook reads identically on iPhone + Android.
153
+ * cold-start windows are deliberately kept the same so the
154
+ * behaviour is identical on iPhone + Android.
155
155
  */
156
156
  private const val HEADLESS_DISPATCH_TIMEOUT_MS = 20_000L
157
157
 
@@ -1455,10 +1455,9 @@ class PlaybackService : HeadlessJsMediaService() {
1455
1455
  override fun getTaskConfig(intent: Intent?): com.facebook.react.jstasks.HeadlessJsTaskConfig? {
1456
1456
  val action = intent?.action ?: return null
1457
1457
  if (action !in HEADLESS_WAKE_ACTIONS) return null
1458
- // Robolectric / non-ReactApplication hosts cannot spawn a JS
1459
- // task — short-circuit here so `Arguments.createMap()` never
1460
- // has to load the React native libs that aren't on the test
1461
- // classpath.
1458
+ // Non-ReactApplication hosts cannot spawn a JS task —
1459
+ // short-circuit here so `Arguments.createMap()` never has to load
1460
+ // the React native libs such a host lacks.
1462
1461
  if ((application as? com.facebook.react.ReactApplication) == null) return null
1463
1462
  // Belt-and-braces: if a wake intent somehow lands while a React
1464
1463
  // context is already alive (foreground app, prior wake already
@@ -73,7 +73,7 @@ internal class PlaybackServiceCallback(
73
73
  * package (Android Auto, Wear OS, Google Assistant) connects.
74
74
  * Production wires the React-context cold-start through here so
75
75
  * "Hey Google, play …" works from a phone with the app killed.
76
- * Default no-op so unit-test setups without a service can fall
76
+ * Default no-op so callers without a wired service fall
77
77
  * through harmlessly. */
78
78
  fun onHeadlessControllerConnect(packageName: String) {}
79
79
 
@@ -287,6 +287,9 @@ internal class PlaybackServiceCallback(
287
287
  ): MediaSession.ConnectionResult {
288
288
  if (controller.packageName in HEADLESS_ELIGIBLE_PACKAGES) {
289
289
  val previousCount = headlessControllerCount.getAndIncrement()
290
+ // A headless controller is now attached — record it in the process-wide
291
+ // count the `isCarConnected()` getter reads.
292
+ CarConnectionState.retain()
290
293
  environment.onHeadlessControllerConnect(controller.packageName)
291
294
  // Fire onCarConnect only on the FIRST headless controller of
292
295
  // this service-lifetime session — subsequent connects (Wear
@@ -348,6 +351,10 @@ internal class PlaybackServiceCallback(
348
351
  } else if (previousCount > 0 && remaining == 0) {
349
352
  environment.onHeadlessControllerDisconnect()
350
353
  }
354
+ // Drop this controller from the process-wide connection count the
355
+ // `isCarConnected()` getter reads (floor-guarded against a stray
356
+ // disconnect, so it can't go negative).
357
+ CarConnectionState.release()
351
358
  }
352
359
  super.onDisconnected(session, controller)
353
360
  }
@@ -3,7 +3,7 @@ package com.margelo.nitro.queueplayer
3
3
  /**
4
4
  * Throttles the JS `onProgress` fan-out to a configurable cadence, with an
5
5
  * optional reduced rate while the app is backgrounded. Pure + deterministic
6
- * (the caller supplies a monotonic timestamp), so it unit-tests directly.
6
+ * (the caller supplies a monotonic timestamp).
7
7
  *
8
8
  * This gates ONLY the JS emission — the native 2 Hz sampler keeps running, so
9
9
  * milestone detection and buffer-state recomputation stay full-rate. A tick
@@ -2,7 +2,7 @@ package com.margelo.nitro.queueplayer
2
2
 
3
3
  /**
4
4
  * Pure-Kotlin index-shift arithmetic for queue mutations. Extracted
5
- * from [TrackPlayer] so the shift semantics are unit-testable without
5
+ * from [TrackPlayer] so the shift semantics stand alone without
6
6
  * an ExoPlayer. Mirrors (and lightly supersedes) the inline math
7
7
  * inside iOS `TrackPlayer.swift`'s `addToQueueBody` /
8
8
  * `removeFromQueueBody` / `moveInQueueBody` — the iOS backlog carries
@@ -4,7 +4,7 @@ package com.margelo.nitro.queueplayer
4
4
  * Pure-Kotlin skip-index arithmetic. Mirrors the iOS sibling
5
5
  * `ios/QueueSkipArithmetic.swift` 1-for-1.
6
6
  *
7
- * Extracted from [TrackPlayer] so the logic is unit-testable without
7
+ * Extracted from [TrackPlayer] so the logic stands alone without
8
8
  * instantiating an ExoPlayer. [TrackPlayer.computeNextIndex] /
9
9
  * [TrackPlayer.computePreviousIndex] are thin adapters that translate
10
10
  * the Nitrogen-generated [RepeatMode] enum into [QueueSkipRepeatMode]
@@ -29,7 +29,7 @@ package com.margelo.nitro.queueplayer
29
29
  *
30
30
  * Bias is toward emitting: when in doubt, act rather than swallow a real press.
31
31
  *
32
- * Pure and time-injected ([nowUptimeMs] is passed in) so it unit-tests without an
32
+ * Pure and time-injected ([nowUptimeMs] is passed in), so it needs no
33
33
  * Android runtime. [shouldEmit] is `@Synchronized` so it is safe regardless of
34
34
  * which thread an emit site runs on (today they are all main-thread-confined).
35
35
  */
@@ -2,7 +2,7 @@ package com.margelo.nitro.queueplayer
2
2
 
3
3
  /**
4
4
  * Pure state machine for the sleep timer — no player, volume, or handler
5
- * dependencies, so it is unit-testable in isolation. The owning [TrackPlayer]
5
+ * dependencies, so it stands alone. The owning [TrackPlayer]
6
6
  * drives it from a repeating main-looper runnable and applies the returned
7
7
  * decision (volume fade, pause) to the engine.
8
8
  *
@@ -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
  }
@@ -46,10 +46,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
46
46
  // --- Stored state
47
47
  //
48
48
  // Mirrors iOS TrackPlayer.swift's "Stored state" block. Field
49
- // visibility is `internal` + `@VisibleForTesting` so Robolectric
50
- // tests can observe lifecycle transitions without going through
51
- // the JNI-backed Nitro Promise machinery (which does not load in
52
- // the JVM unit-test runtime).
49
+ // visibility is `internal` + `@VisibleForTesting` so lifecycle
50
+ // transitions can be observed without going through the JNI-backed
51
+ // Nitro Promise machinery (which does not load on a plain JVM).
53
52
 
54
53
  /**
55
54
  * `true` after [configureInternal] has bound + configured the
@@ -158,8 +157,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
158
157
  private var lastEmittedFormatMediaItem: MediaItem? = null
159
158
 
160
159
  // The fields below are written on main (via `onMain` / `syncMain`
161
- // from the Promise adapter path, or directly from Robolectric tests
162
- // which run on main). They are read off-main by the synchronous
160
+ // from the Promise adapter path). They are read off-main by the synchronous
163
161
  // snapshot accessors — `getCurrentTrackIndex`, `getQueue` —
164
162
  // dispatched on the JS thread by Nitro. `@Volatile` gives the
165
163
  // read path a happens-before edge on the most recent main-thread
@@ -581,7 +579,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
581
579
  private val progressEmissionGate = ProgressEmissionGate()
582
580
  // ProcessLifecycleOwner observer driving the gate's background state. Held in
583
581
  // a nullable field so a re-configureInternal without an intervening
584
- // destroyInternal (Robolectric) is a no-op instead of leaking a second
582
+ // destroyInternal is a no-op instead of leaking a second
585
583
  // observer. Registered in configureInternal, removed in destroyInternal.
586
584
  private var progressLifecycleObserver: DefaultLifecycleObserver? = null
587
585
  private val errorListeners = ListenerRegistry<(PlaybackError) -> Unit>()
@@ -866,11 +864,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
866
864
  }
867
865
 
868
866
  /**
869
- * Lifecycle configure body. `internal` so Robolectric tests can
870
- * exercise it without the Nitro Promise path. Callers MUST hop
871
- * to the main thread first ExoPlayer construction binds to the
872
- * calling Looper. Production path goes through `onMain`; tests
873
- * run on Robolectric's main-thread dispatcher.
867
+ * Lifecycle configure body. `internal`, bypassing the Nitro Promise
868
+ * path. Callers MUST hop to the main thread first ExoPlayer
869
+ * construction binds to the calling Looper. Production path goes
870
+ * through `onMain`.
874
871
  *
875
872
  * Builds the ExoPlayer with a [MediaSource.Factory] that attaches
876
873
  * the config's HTTP headers + User-Agent on every network request.
@@ -879,9 +876,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
879
876
  * Per the universal auto-destroy contract every `configure()` call
880
877
  * tears the player down upstream so this body is purely
881
878
  * constructive — `player` is guaranteed null on entry from the
882
- * production path. Robolectric tests that drive `configureInternal`
883
- * directly are responsible for calling `destroyInternal` first
884
- * between configures.
879
+ * production path. Any caller driving `configureInternal` directly
880
+ * must call `destroyInternal` first between configures.
885
881
  */
886
882
  @VisibleForTesting
887
883
  @MainThread
@@ -2442,9 +2438,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2442
2438
  /**
2443
2439
  * Main-thread body for [setPlaybackSpeed]. The engine applies the
2444
2440
  * active pitch-correction mode's pitch alongside the speed (voice/music
2445
- * preserve pitch; none lets it follow rate). Exposed `@VisibleForTesting`
2446
- * so Robolectric exercises the cast + speed-application logic rather than
2447
- * just bypassing through `ExoPlayer.playbackParameters`.
2441
+ * preserve pitch; none lets it follow rate). Exposed `@VisibleForTesting`;
2442
+ * centralizes the cast + speed-application logic rather than routing
2443
+ * straight through `ExoPlayer.playbackParameters`.
2448
2444
  *
2449
2445
  * Diverges from iOS: on iOS `player.rate = Float(rate)` implicitly
2450
2446
  * starts playback when the player was paused. Android's
@@ -2629,6 +2625,14 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2629
2625
  positionSec = maxOf(0L, serviceBinder?.engine?.currentPositionMs ?: 0L) / 1000.0
2630
2626
  }
2631
2627
  val result = sleepTimerCore.tick(System.currentTimeMillis(), durationSec, positionSec)
2628
+ applySleepTimerResult(result)
2629
+ }
2630
+
2631
+ /**
2632
+ * Apply a [SleepTimerCore.Tick] decision — fade, state emit, boundary pause.
2633
+ * Shared by the polling tick and the real track-end signal ([fireEndOfTrackSleepTimer]).
2634
+ */
2635
+ private fun applySleepTimerResult(result: SleepTimerCore.Tick) {
2632
2636
  applySleepTimerFade(result.fadeFraction)
2633
2637
  if (result.stateChanged) emitSleepTimerChanged()
2634
2638
  if (result.pauseNow) {
@@ -2643,6 +2647,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2643
2647
  if (!sleepTimerCore.isActive) stopSleepTimerTick()
2644
2648
  }
2645
2649
 
2650
+ /**
2651
+ * Fire the end-of-track sleep timer at a real auto-transition boundary (the
2652
+ * current track finished and Media3 advanced). Called from the engine's
2653
+ * track-transition callback so the pause lands at the boundary and fires even
2654
+ * when the track duration was never known — no-op unless end-of-track is armed.
2655
+ */
2656
+ private fun fireEndOfTrackSleepTimer() {
2657
+ val result = sleepTimerCore.fireAtTrackEnd()
2658
+ if (result.pauseNow) applySleepTimerResult(result)
2659
+ }
2660
+
2646
2661
  private fun applySleepTimerFade(fraction: Double) {
2647
2662
  // Fade is local-only: the receiver's volume is user/system-owned and
2648
2663
  // `volumeState` tracks the local level, so ramping it on the receiver would
@@ -3013,6 +3028,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3013
3028
  override fun onCarDisconnect(callback: () -> Unit): () -> Unit =
3014
3029
  deferredRegister { it.registerOnCarDisconnect(callback) }
3015
3030
 
3031
+ override fun isCarConnected(): Boolean = CarConnectionState.isConnected()
3032
+
3016
3033
  override fun onServiceReady(
3017
3034
  callback: (reason: ServiceReadyReason) -> Unit
3018
3035
  ): () -> Unit = deferredRegister { it.registerOnServiceReady(callback) }
@@ -3599,6 +3616,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3599
3616
  internal fun maybeEmitQueueEnd(playbackState: Int) {
3600
3617
  if (playbackState != Player.STATE_ENDED) return
3601
3618
  if (tracks.isEmpty()) return
3619
+ // End-of-track sleep timer on the LAST track: STATE_ENDED fires no
3620
+ // MEDIA_ITEM_TRANSITION, so cover the final boundary here — matching iOS,
3621
+ // whose per-item handlePlayerItemDidPlayToEndTime fires on the last track
3622
+ // too. Idempotent, so it can't double-fire with the poll / transition path.
3623
+ fireEndOfTrackSleepTimer()
3602
3624
  queueEndListeners.forEach { it() }
3603
3625
  emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
3604
3626
  }
@@ -3622,6 +3644,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3622
3644
  val p = player ?: return
3623
3645
  val translated = translateTransitionReason(reason, p)
3624
3646
 
3647
+ // End-of-track sleep timer: a natural track boundary — auto-advance to the
3648
+ // next track OR a repeat-one loop of the same track (both translate to
3649
+ // AUTO_ADVANCE) — is the real end of the current track, so pause here.
3650
+ // fireAtTrackEnd is idempotent, so a deduped crossfade echo can't double-fire.
3651
+ if (translated == TrackChangeReason.AUTO_ADVANCE) fireEndOfTrackSleepTimer()
3652
+
3625
3653
  // Natural track change → reset error dedup so a new item's
3626
3654
  // identical-coded error fires. Without this, a TIMEOUT on
3627
3655
  // track 2 (after a TIMEOUT on track 1) would be silently
@@ -4127,11 +4155,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4127
4155
  }
4128
4156
 
4129
4157
  /**
4130
- * Test hook: stash a [TrackChangeReason] that the next
4158
+ * Stash a [TrackChangeReason] that the next
4131
4159
  * [handleMediaItemTransition] with `REASON_SEEK` will consume.
4132
- * Production code reaches this field via `skipTo*Internal`
4133
- * bodies; the hook just lets Robolectric tests simulate the
4134
- * stash without driving a real skip + ExoPlayer seek.
4160
+ * Production code reaches this field via `skipTo*Internal` bodies.
4135
4161
  */
4136
4162
  @VisibleForTesting
4137
4163
  internal fun setPendingTrackChangeReasonForTest(reason: TrackChangeReason?) {
@@ -4139,9 +4165,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4139
4165
  }
4140
4166
 
4141
4167
  /**
4142
- * Test hook: reset the [lastReportedState] dedup edge so
4143
- * [emitStateIfChanged] tests can force an emit from an already-
4144
- * NONE state without going through a real Player.Listener fire.
4168
+ * Reset the [lastReportedState] dedup edge, forcing [emitStateIfChanged]
4169
+ * to emit from an already-NONE state without a real Player.Listener fire.
4145
4170
  */
4146
4171
  @VisibleForTesting
4147
4172
  internal fun resetLastReportedStateForTest() {
@@ -4220,8 +4245,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4220
4245
 
4221
4246
  /**
4222
4247
  * Decide which [ServiceReadyReason] (if any) to dispatch on a
4223
- * fresh service-bind. Pure function so the reason-decision logic
4224
- * is unit-testable without spinning up a service controller.
4248
+ * fresh service-bind. Pure function no service controller needed.
4225
4249
  *
4226
4250
  * - First-ever bind ([prior] == null): [ServiceReadyReason.FIRST_BIND].
4227
4251
  * - Bind against a different binder identity:
@@ -7,8 +7,8 @@ import androidx.media3.common.Player
7
7
  * isPlaying, playWhenReady, playbackSuppressionReason, hasTracks,
8
8
  * currentTrackIndex)` tuple to the library's [PlayerState] enum.
9
9
  *
10
- * Extracted from [TrackPlayer.getStateInternal] so every branch is
11
- * unit-testable without a live ExoPlayer. Matches the pattern of
10
+ * Extracted from [TrackPlayer.getStateInternal] so every branch
11
+ * stands alone without a live ExoPlayer. Matches the pattern of
12
12
  * [QueueSkipArithmetic] + [QueueMutationArithmetic] — TrackPlayer
13
13
  * is a thin adapter that reads the live player's state and
14
14
  * delegates the translation here.
@@ -14,12 +14,9 @@ import java.lang.reflect.Field
14
14
  /**
15
15
  * Unit coverage for [AudioPipelineRenderersFactory] construction — it wraps a
16
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
- * `buildAudioSink` via `ExoPlayer.Builder.build()` in
19
- * [GaplessEngineLifecycleTest] / [CrossfadeEngineLifecycleTest]; `buildAudioSink`
17
+ * the produced sink at 16-bit (float output off). `buildAudioSink`
20
18
  * itself is `protected` on [DefaultRenderersFactory] and cannot be called
21
- * outside the Media3 internal call path. Chain composition + order is covered by
22
- * [PitchAwareAudioProcessorChainTest].
19
+ * outside the Media3 internal call path.
23
20
  */
24
21
  @RunWith(RobolectricTestRunner::class)
25
22
  @Config(sdk = [34])
@@ -9,10 +9,7 @@ import org.junit.Test
9
9
  /**
10
10
  * Pure-Kotlin coverage for [BrowseCallbackRegistry]'s slot-replace
11
11
  * + dispose semantics. The Promise-typed callbacks are stored as
12
- * function references; tests use simple stand-in lambdas — actual
13
- * dispatch coverage lives at the integration layer
14
- * (`PlaybackServiceCallbackSearchTest` for the search dispatch
15
- * path; future Maestro flows for end-to-end Auto cold-wake).
12
+ * function references; tests use simple stand-in lambdas.
16
13
  */
17
14
  @UnstableApi
18
15
  class BrowseCallbackRegistryTest {
@@ -15,8 +15,7 @@ import org.robolectric.annotation.Config
15
15
  /**
16
16
  * Lifecycle + fade-math coverage for [CrossfadeEngine].
17
17
  *
18
- * Audible verification of the dual-player crossfade runs at the
19
- * Maestro / demo-app integration layer; this suite exercises:
18
+ * This suite exercises:
20
19
  * empty-state sentinels, role-swap arithmetic, equal-power gain
21
20
  * curve, duration-clamp formula, manual-skip cancellation,
22
21
  * release idempotence + delegate clearing.
@@ -24,9 +24,7 @@ import org.robolectric.annotation.Config
24
24
  * Media3 metadata dispatch is not emulated by Robolectric without
25
25
  * playback, so the listener body composes its work through the
26
26
  * engine-internal `handleReplayGainParse(isA, parsed)` helper which
27
- * these tests drive directly with synthetic [ReplayGainData]. The
28
- * pure-math piece is covered in [ReplayGainGainTest]; end-to-end
29
- * propagation lands in the demo app's `replaygain` Maestro suite.
27
+ * these tests drive directly with synthetic [ReplayGainData].
30
28
  * This file covers the no-data baseline + the per-leg
31
29
  * `handleReplayGainParse` cache + recompute contract.
32
30
  */
@@ -173,8 +171,6 @@ class CrossfadeEngineReplayGainTest {
173
171
 
174
172
  // The per-leg cache-clear-on-transition path lives inline in each
175
173
  // leg's `Player.Listener.onMediaItemTransition` body. Robolectric
176
- // does not drive that callback without real playback; integration
177
- // coverage is in `RNQP-Demo/src/tests/suites/replaygain.ts` (steps
178
- // `cross-track-active-updates` +
179
- // `skip-next-mid-playback-resets-from-stale-cache`).
174
+ // does not drive that callback without real playback, so it is out
175
+ // of scope here.
180
176
  }
@@ -13,8 +13,7 @@ import org.robolectric.annotation.Config
13
13
  /**
14
14
  * State-mirror coverage for [EqualizerEngine] on the API 28+ path.
15
15
  * Robolectric stubs the audio-effect subsystem at this SDK level so
16
- * `attach` exercises the construction path without throwing; the
17
- * audible effect is verified at integration via demo Maestro.
16
+ * `attach` exercises the construction path without throwing.
18
17
  */
19
18
  @RunWith(RobolectricTestRunner::class)
20
19
  @Config(sdk = [34])
@@ -16,9 +16,7 @@ import org.robolectric.annotation.Config
16
16
  /**
17
17
  * Wiring coverage for the `Equalizer` HybridObject — engine
18
18
  * selection, JS-facing surface routing through the synchronous
19
- * `*Internal` core, listener registry behaviour. The audible
20
- * effect is verified at the integration layer via demo Maestro
21
- * once the engine is hooked to ExoPlayer's audio session.
19
+ * `*Internal` core, listener registry behaviour.
22
20
  */
23
21
  @RunWith(RobolectricTestRunner::class)
24
22
  @Config(sdk = [34])
@@ -13,9 +13,8 @@ import org.robolectric.annotation.Config
13
13
  * State-mirror coverage for [EqualizerLegacyEngine] on the API <28 path.
14
14
  * Robolectric's audio-effect shadow doesn't model the hardware-side
15
15
  * band centre frequencies + gain range; the band-mapping + dB↔mB
16
- * conversion are the test-reachable invariants. Real audible effect
17
- * verified at user-led API 26 emulator playbook (legacy API
18
- * behaviour varies per OEM).
16
+ * conversion are the invariants this covers. Legacy AudioEffect
17
+ * behaviour varies per OEM.
19
18
  */
20
19
  @RunWith(RobolectricTestRunner::class)
21
20
  @Config(sdk = [27])