react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29

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 (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,42 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ @Suite("Player.playingState")
6
+ @MainActor
7
+ struct PlayingStateTests {
8
+ private func state(_ playWhenReady: Bool, _ state: PlaybackState) -> PlayingState {
9
+ Player.playingState(playWhenReady: playWhenReady, state: state)
10
+ }
11
+
12
+ @Test func playingWithIntentInAnActiveState() {
13
+ #expect(state(true, .playing).playing)
14
+ #expect(!state(true, .playing).buffering)
15
+ }
16
+
17
+ @Test func bufferingWithIntentWhileLoading() {
18
+ #expect(state(true, .loading).playing)
19
+ #expect(state(true, .loading).buffering)
20
+ }
21
+
22
+ @Test func notPlayingWithoutIntent() {
23
+ #expect(!state(false, .playing).playing)
24
+ }
25
+
26
+ /// .stopped was once missing here, masked only by stop() clearing the intent right after.
27
+ @Test func notPlayingInTerminalStatesEvenWithIntent() {
28
+ for terminal in [PlaybackState.none, .stopped, .ended, .error] {
29
+ #expect(!state(true, terminal).playing, "Expected not playing in \(terminal) despite intent")
30
+ }
31
+ }
32
+
33
+ @Test func aChangeIsPublishedOnce() {
34
+ let callbacks = MockPlayerCallbacks()
35
+ let player = makePlayer()
36
+ player.callbacks = callbacks
37
+ player.transition(.avPlayerPlaying)
38
+ player.setPlayWhenReady(true)
39
+ player.setPlayWhenReady(true)
40
+ #expect(callbacks.playingStateChanges.count == 1)
41
+ }
42
+ }
@@ -9,16 +9,6 @@ private func tracks(_ ids: String...) -> [Track] {
9
9
  ids.map { Track(id: $0) }
10
10
  }
11
11
 
12
- /// A delegate that records every `queueDidChangeTracks` callback.
13
- @MainActor
14
- private final class DelegateSpy: QueueManagerDelegate {
15
- var calls: [[Track]] = []
16
-
17
- func queueDidChangeTracks(_ tracks: [Track]) {
18
- calls.append(tracks)
19
- }
20
- }
21
-
22
12
  // MARK: - Navigation: next()
23
13
 
24
14
  @Suite("next()")
@@ -338,9 +328,9 @@ struct SetQueueTests {
338
328
  #expect(q.queueSourcePath == "/albums/123")
339
329
  }
340
330
 
341
- @Test func returnsTrue() {
331
+ @Test func changesTracksAndCurrent() {
342
332
  let q = QueueManager()
343
- #expect(q.setQueue(tracks("a")) == true)
333
+ #expect(q.setQueue(tracks("a")) == .tracksAndCurrent)
344
334
  }
345
335
 
346
336
  @Test func emptyArray_setsCurrentIndexToZero() {
@@ -373,9 +363,9 @@ struct SetQueueTests {
373
363
  @Suite("add()")
374
364
  @MainActor
375
365
  struct AddTests {
376
- @Test func toEmptyQueue_returnsTrue_setsCurrentIndex() {
366
+ @Test func toEmptyQueue_changesCurrent_setsCurrentIndex() {
377
367
  let q = QueueManager()
378
- let changed = q.add(tracks("a", "b"))
368
+ let changed = q.add(tracks("a", "b")).currentTrack
379
369
  #expect(changed == true)
380
370
  #expect(q.currentIndex == 0)
381
371
  #expect(q.tracks.count == 2)
@@ -383,28 +373,28 @@ struct AddTests {
383
373
 
384
374
  @Test func toEmptyQueue_respectsInitialIndex() {
385
375
  let q = QueueManager()
386
- let changed = q.add(tracks("a", "b", "c"), initialIndex: 2)
376
+ let changed = q.add(tracks("a", "b", "c"), initialIndex: 2).currentTrack
387
377
  #expect(changed == true)
388
378
  #expect(q.currentIndex == 2)
389
379
  }
390
380
 
391
- @Test func toNonEmpty_returnsFalse_currentIndexUnchanged() {
381
+ @Test func toNonEmpty_keepsCurrent_currentIndexUnchanged() {
392
382
  let q = QueueManager()
393
383
  q.setQueue(tracks("a"))
394
- let changed = q.add(tracks("b", "c"))
384
+ let changed = q.add(tracks("b", "c")).currentTrack
395
385
  #expect(changed == false)
396
386
  #expect(q.currentIndex == 0)
397
387
  #expect(q.tracks.count == 3)
398
388
  }
399
389
 
400
- @Test func emptyArray_returnsFalse() {
390
+ @Test func emptyArray_changesNothing() {
401
391
  let q = QueueManager()
402
- #expect(q.add([]) == false)
392
+ #expect(q.add([]) == .none)
403
393
  }
404
394
 
405
395
  @Test func toEmptyQueue_clampsInitialIndex() {
406
396
  let q = QueueManager()
407
- let changed = q.add(tracks("a", "b"), initialIndex: 99)
397
+ let changed = q.add(tracks("a", "b"), initialIndex: 99).currentTrack
408
398
  #expect(changed == true)
409
399
  #expect(q.currentIndex == 1)
410
400
  }
@@ -446,7 +436,7 @@ struct AddAtTests {
446
436
  @Test func beforeCurrent_shiftsCurrentIndex() throws {
447
437
  let q = QueueManager()
448
438
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
449
- let changed = try q.addAt(tracks("x", "y"), at: 0)
439
+ let changed = try q.addAt(tracks("x", "y"), at: 0).currentTrack
450
440
  #expect(changed == false)
451
441
  #expect(q.currentIndex == 3) // was 1, shifted by 2
452
442
  #expect(q.tracks.count == 5)
@@ -455,15 +445,15 @@ struct AddAtTests {
455
445
  @Test func afterCurrent_currentIndexUnchanged() throws {
456
446
  let q = QueueManager()
457
447
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
458
- let changed = try q.addAt(tracks("x"), at: 2)
448
+ let changed = try q.addAt(tracks("x"), at: 2).currentTrack
459
449
  #expect(changed == false)
460
450
  #expect(q.currentIndex == 0)
461
451
  #expect(q.tracks.count == 4)
462
452
  }
463
453
 
464
- @Test func toEmptyQueue_returnsTrue() throws {
454
+ @Test func toEmptyQueue_changesCurrent() throws {
465
455
  let q = QueueManager()
466
- let changed = try q.addAt(tracks("a"), at: 0)
456
+ let changed = try q.addAt(tracks("a"), at: 0).currentTrack
467
457
  #expect(changed == true)
468
458
  #expect(q.currentIndex == 0)
469
459
  }
@@ -496,16 +486,16 @@ struct AddAtTests {
496
486
  }
497
487
  }
498
488
 
499
- @Test func emptyArray_returnsFalse() throws {
489
+ @Test func emptyArray_changesNothing() throws {
500
490
  let q = QueueManager()
501
491
  q.setQueue(tracks("a"))
502
- #expect(try q.addAt([], at: 0) == false)
492
+ #expect(try q.addAt([], at: 0) == .none)
503
493
  }
504
494
 
505
495
  @Test func singleExistingTrack_insertBeforeCurrent_shifts() throws {
506
496
  let q = QueueManager()
507
497
  q.setQueue(tracks("a"))
508
- let changed = try q.addAt(tracks("x"), at: 0)
498
+ let changed = try q.addAt(tracks("x"), at: 0).currentTrack
509
499
  #expect(changed == false)
510
500
  // The pointer follows the playing track — the old `tracks.count > 1`
511
501
  // guard silently swapped currentTrack to the inserted track while the
@@ -518,7 +508,7 @@ struct AddAtTests {
518
508
  let q = QueueManager()
519
509
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
520
510
  // currentIndex >= index (1 >= 1) triggers the shift
521
- let changed = try q.addAt(tracks("x"), at: 1)
511
+ let changed = try q.addAt(tracks("x"), at: 1).currentTrack
522
512
  #expect(changed == false)
523
513
  #expect(q.currentIndex == 2)
524
514
  // "b" should still be the current track
@@ -529,7 +519,7 @@ struct AddAtTests {
529
519
  let q = QueueManager()
530
520
  q.setQueue(tracks("a", "b"))
531
521
  // index == tracks.count is the valid append boundary
532
- let changed = try q.addAt(tracks("x"), at: 2)
522
+ let changed = try q.addAt(tracks("x"), at: 2).currentTrack
533
523
  #expect(changed == false)
534
524
  #expect(q.tracks.count == 3)
535
525
  #expect(q.tracks[2].id == "x")
@@ -542,29 +532,29 @@ struct AddAtTests {
542
532
  @Suite("remove()")
543
533
  @MainActor
544
534
  struct RemoveTests {
545
- @Test func currentTrack_returnsTrue_wrapsIndex() throws {
535
+ @Test func currentTrack_changesCurrent_wrapsIndex() throws {
546
536
  let q = QueueManager()
547
537
  q.setQueue(tracks("a", "b", "c"), initialIndex: 2)
548
- let changed = try q.remove(2)
538
+ let changed = try q.remove(2).currentTrack
549
539
  #expect(changed == true)
550
540
  // After removing index 2, tracks has 2 items, currentIndex wraps: 2 % 2 = 0
551
541
  #expect(q.currentIndex == 0)
552
542
  #expect(q.tracks.count == 2)
553
543
  }
554
544
 
555
- @Test func beforeCurrent_adjustsIndex_returnsFalse() throws {
545
+ @Test func beforeCurrent_adjustsIndex_keepsCurrent() throws {
556
546
  let q = QueueManager()
557
547
  q.setQueue(tracks("a", "b", "c"), initialIndex: 2)
558
- let changed = try q.remove(0)
548
+ let changed = try q.remove(0).currentTrack
559
549
  #expect(changed == false)
560
550
  #expect(q.currentIndex == 1) // was 2, decremented
561
551
  #expect(q.tracks.count == 2)
562
552
  }
563
553
 
564
- @Test func afterCurrent_returnsFalse() throws {
554
+ @Test func afterCurrent_keepsCurrent() throws {
565
555
  let q = QueueManager()
566
556
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
567
- let changed = try q.remove(2)
557
+ let changed = try q.remove(2).currentTrack
568
558
  #expect(changed == false)
569
559
  #expect(q.currentIndex == 0)
570
560
  #expect(q.tracks.count == 2)
@@ -573,7 +563,7 @@ struct RemoveTests {
573
563
  @Test func lastRemaining_currentIndexBecomesMinusOne() throws {
574
564
  let q = QueueManager()
575
565
  q.setQueue(tracks("a"))
576
- let changed = try q.remove(0)
566
+ let changed = try q.remove(0).currentTrack
577
567
  #expect(changed == true)
578
568
  #expect(q.currentIndex == -1)
579
569
  #expect(q.tracks.isEmpty)
@@ -605,7 +595,7 @@ struct RemoveTests {
605
595
  @Test func currentAtMiddle_wrapsCorrectly() throws {
606
596
  let q = QueueManager()
607
597
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
608
- let changed = try q.remove(1)
598
+ let changed = try q.remove(1).currentTrack
609
599
  #expect(changed == true)
610
600
  // 1 % 2 = 1, pointing to what was "c"
611
601
  #expect(q.currentIndex == 1)
@@ -615,7 +605,7 @@ struct RemoveTests {
615
605
  @Test func currentAtZero_staysAtZero() throws {
616
606
  let q = QueueManager()
617
607
  q.setQueue(tracks("a", "b", "c"))
618
- let changed = try q.remove(0)
608
+ let changed = try q.remove(0).currentTrack
619
609
  #expect(changed == true)
620
610
  // 0 % 2 = 0
621
611
  #expect(q.currentIndex == 0)
@@ -643,16 +633,16 @@ struct MoveTests {
643
633
  @Test func currentTrack_pointerFollows() throws {
644
634
  let q = QueueManager()
645
635
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
646
- let changed = try q.move(fromIndex: 0, toIndex: 2)
636
+ let changed = try q.move(fromIndex: 0, toIndex: 2).currentTrack
647
637
  #expect(changed == false)
648
638
  #expect(q.currentIndex == 2)
649
639
  #expect(q.currentTrack?.id == "a")
650
640
  }
651
641
 
652
- @Test func nonCurrent_returnsFalse() throws {
642
+ @Test func nonCurrent_keepsCurrent() throws {
653
643
  let q = QueueManager()
654
644
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
655
- let changed = try q.move(fromIndex: 1, toIndex: 2)
645
+ let changed = try q.move(fromIndex: 1, toIndex: 2).currentTrack
656
646
  #expect(changed == false)
657
647
  #expect(q.currentIndex == 0)
658
648
  }
@@ -667,7 +657,7 @@ struct MoveTests {
667
657
  @Test func nonCurrent_acrossCurrent_keepsPointerOnSameTrack() throws {
668
658
  let q = QueueManager()
669
659
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
670
- let changed = try q.move(fromIndex: 0, toIndex: 2)
660
+ let changed = try q.move(fromIndex: 0, toIndex: 2).currentTrack
671
661
  #expect(changed == false)
672
662
  // [a,b,c] → [b,c,a]; "b" is now index 0.
673
663
  #expect(q.currentTrack?.id == "b")
@@ -677,7 +667,7 @@ struct MoveTests {
677
667
  @Test func nonCurrent_acrossCurrentBackwards_keepsPointerOnSameTrack() throws {
678
668
  let q = QueueManager()
679
669
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
680
- let changed = try q.move(fromIndex: 2, toIndex: 0)
670
+ let changed = try q.move(fromIndex: 2, toIndex: 0).currentTrack
681
671
  #expect(changed == false)
682
672
  // [a,b,c] → [c,a,b]; "b" is now index 2.
683
673
  #expect(q.currentTrack?.id == "b")
@@ -687,7 +677,7 @@ struct MoveTests {
687
677
  @Test func toIndexBeyondCount_clampsPointerToo() throws {
688
678
  let q = QueueManager()
689
679
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
690
- let changed = try q.move(fromIndex: 0, toIndex: 99)
680
+ let changed = try q.move(fromIndex: 0, toIndex: 99).currentTrack
691
681
  #expect(changed == false)
692
682
  // remove(0): [b, c], insert at min(2, 99)=2: [b, c, a]
693
683
  #expect(q.tracks[2].id == "a")
@@ -719,19 +709,19 @@ struct MoveTests {
719
709
  @Suite("clear()")
720
710
  @MainActor
721
711
  struct ClearTests {
722
- @Test func withTracks_returnsTrue_resetsToMinusOne() {
712
+ @Test func withTracks_changesCurrent_resetsToMinusOne() {
723
713
  let q = QueueManager()
724
714
  q.setQueue(tracks("a", "b"))
725
- let changed = q.clear()
715
+ let changed = q.clear().currentTrack
726
716
  #expect(changed == true)
727
717
  #expect(q.currentIndex == -1)
728
718
  #expect(q.tracks.isEmpty)
729
719
  #expect(q.queueSourcePath == nil)
730
720
  }
731
721
 
732
- @Test func alreadyEmpty_returnsFalse() {
722
+ @Test func alreadyEmpty_changesNothing() {
733
723
  let q = QueueManager()
734
- #expect(q.clear() == false)
724
+ #expect(q.clear() == .none)
735
725
  }
736
726
 
737
727
  /// clear() and removeUpcomingTracks() were the only mutations not
@@ -941,95 +931,74 @@ struct ComputedPropertyTests {
941
931
  }
942
932
  }
943
933
 
944
- // MARK: - Delegate
934
+ // MARK: - QueueChange
945
935
 
946
- @Suite("delegate")
936
+ @Suite("QueueChange")
947
937
  @MainActor
948
- struct DelegateTests {
949
- @Test func queueDidChangeTracks_firesOnSetQueue() {
938
+ struct QueueChangeTests {
939
+ @Test func setQueue_changesTracksAndCurrent() {
940
+ #expect(QueueManager().setQueue(tracks("a", "b")) == .tracksAndCurrent)
941
+ }
942
+
943
+ @Test func addToEmpty_changesTracksAndCurrent() {
944
+ #expect(QueueManager().add(tracks("a")) == .tracksAndCurrent)
945
+ }
946
+
947
+ @Test func addToNonEmpty_changesTracksOnly() {
948
+ let q = QueueManager()
949
+ q.setQueue(tracks("a"))
950
+ #expect(q.add(tracks("b")) == .tracksOnly)
951
+ }
952
+
953
+ @Test func addAt_changesTracksOnly() throws {
950
954
  let q = QueueManager()
951
- let spy = DelegateSpy()
952
- q.delegate = spy
953
955
  q.setQueue(tracks("a", "b"))
954
- #expect(spy.calls.count == 1)
955
- #expect(spy.calls[0].count == 2)
956
+ #expect(try q.addAt(tracks("x"), at: 1) == .tracksOnly)
957
+ #expect(q.tracks.count == 3)
956
958
  }
957
959
 
958
- @Test func queueDidChangeTracks_firesOnAdd() {
960
+ @Test func removeCurrent_changesTracksAndCurrent() throws {
959
961
  let q = QueueManager()
960
- let spy = DelegateSpy()
961
- q.delegate = spy
962
- q.add(tracks("a"))
963
- #expect(spy.calls.count == 1)
962
+ q.setQueue(tracks("a", "b"))
963
+ #expect(try q.remove(0) == .tracksAndCurrent)
964
964
  }
965
965
 
966
- @Test func queueDidChangeTracks_firesOnRemove() throws {
966
+ @Test func removeOther_changesTracksOnly() throws {
967
967
  let q = QueueManager()
968
968
  q.setQueue(tracks("a", "b"))
969
- let spy = DelegateSpy()
970
- q.delegate = spy
971
- try q.remove(1)
972
- #expect(spy.calls.count == 1)
973
- #expect(spy.calls[0].count == 1)
969
+ #expect(try q.remove(1) == .tracksOnly)
974
970
  }
975
971
 
976
- @Test func queueDidChangeTracks_firesOnClear() {
972
+ @Test func clear_changesTracksAndCurrent() {
977
973
  let q = QueueManager()
978
974
  q.setQueue(tracks("a"))
979
- let spy = DelegateSpy()
980
- q.delegate = spy
981
- q.clear()
982
- #expect(spy.calls.count == 1)
983
- #expect(spy.calls[0].isEmpty)
975
+ #expect(q.clear() == .tracksAndCurrent)
976
+ #expect(q.tracks.isEmpty)
984
977
  }
985
978
 
986
- @Test func queueDidChangeTracks_firesOnMove() throws {
979
+ @Test func moveCurrent_changesTracksOnly() throws {
987
980
  let q = QueueManager()
988
981
  q.setQueue(tracks("a", "b", "c"))
989
- let spy = DelegateSpy()
990
- q.delegate = spy
991
- try q.move(fromIndex: 0, toIndex: 2)
992
- #expect(spy.calls.count == 1)
982
+ #expect(try q.move(fromIndex: 0, toIndex: 2) == .tracksOnly)
993
983
  }
994
984
 
995
- @Test func queueDidChangeTracks_firesOnReplace() throws {
985
+ @Test func replaceCurrent_changesTracksOnly() throws {
996
986
  let q = QueueManager()
997
987
  q.setQueue(tracks("a", "b"))
998
- let spy = DelegateSpy()
999
- q.delegate = spy
1000
- try q.replace(0, Track(id: "A"))
1001
- #expect(spy.calls.count == 1)
988
+ #expect(try q.replace(0, Track(id: "A")) == .tracksOnly)
1002
989
  }
1003
990
 
1004
- @Test func queueDidChangeTracks_firesOnRemoveUpcomingTracks() {
991
+ @Test func removeUpcomingTracks_changesTracksOnly() {
1005
992
  let q = QueueManager()
1006
993
  q.setQueue(tracks("a", "b", "c"), initialIndex: 0)
1007
- let spy = DelegateSpy()
1008
- q.delegate = spy
1009
- q.removeUpcomingTracks()
1010
- #expect(spy.calls.count == 1)
1011
- #expect(spy.calls[0].count == 1)
1012
- }
1013
-
1014
- @Test func queueDidChangeTracks_firesOnAddAt() throws {
1015
- let q = QueueManager()
1016
- q.setQueue(tracks("a", "b"))
1017
- let spy = DelegateSpy()
1018
- q.delegate = spy
1019
- try q.addAt(tracks("x"), at: 1)
1020
- #expect(spy.calls.count == 1)
1021
- #expect(spy.calls[0].count == 3)
994
+ #expect(q.removeUpcomingTracks() == .tracksOnly)
995
+ #expect(q.tracks.count == 1)
1022
996
  }
1023
997
 
1024
- @Test func queueDidChangeTracks_doesNotFireOnNavigation() throws {
998
+ @Test func removeUpcomingTracks_atEnd_changesNothing() {
1025
999
  let q = QueueManager()
1026
- q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
1027
- let spy = DelegateSpy()
1028
- q.delegate = spy
1029
- _ = q.next()
1030
- _ = q.previous()
1031
- try q.skipTo(2)
1032
- #expect(spy.calls.isEmpty)
1000
+ q.setQueue(tracks("a", "b"), initialIndex: 1)
1001
+ #expect(q.removeUpcomingTracks() == .none)
1033
1002
  }
1034
1003
  }
1035
1004
 
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @MainActor
7
+ @Suite("ReadyValue")
8
+ struct ReadyValueTests {
9
+ final class Source {
10
+ var value: Int?
11
+ }
12
+
13
+ @Test func returnsAtOnceWhenTheProviderHasAValue() async {
14
+ let ready = ReadyValue<Int> { 7 }
15
+ #expect(await ready.wait() == 7)
16
+ }
17
+
18
+ @Test func resumesEveryWaiterOnCheck() async {
19
+ let source = Source()
20
+ let ready = ReadyValue { source.value }
21
+ let first = Task { await ready.wait() }
22
+ let second = Task { await ready.wait() }
23
+ while ready.waiterCount < 2 {
24
+ await Task.yield()
25
+ }
26
+ ready.check()
27
+ #expect(ready.waiterCount == 2)
28
+ source.value = 3
29
+ ready.check()
30
+ #expect(await first.value == 3)
31
+ #expect(await second.value == 3)
32
+ #expect(ready.waiterCount == 0)
33
+ }
34
+
35
+ @Test func followsTheProviderWithoutAReset() async {
36
+ let source = Source()
37
+ source.value = 1
38
+ let ready = ReadyValue { source.value }
39
+ #expect(await ready.wait() == 1)
40
+ source.value = 2
41
+ #expect(await ready.wait() == 2)
42
+ }
43
+
44
+ @Test func timesOutWithNilAndDropsTheWaiter() async {
45
+ let ready = ReadyValue<Int> { nil }
46
+ #expect(await ready.wait(timeout: .milliseconds(10)) == nil)
47
+ #expect(ready.waiterCount == 0)
48
+ }
49
+
50
+ @Test func aCancelledWaiterGetsNilAndIsDropped() async {
51
+ let ready = ReadyValue<Int> { nil }
52
+ let waiter = Task { await ready.wait() }
53
+ while ready.waiterCount < 1 {
54
+ await Task.yield()
55
+ }
56
+ waiter.cancel()
57
+ #expect(await waiter.value == nil)
58
+ while ready.waiterCount > 0 {
59
+ await Task.yield()
60
+ }
61
+ #expect(ready.waiterCount == 0)
62
+ }
63
+
64
+ @Test func aWaiterCancelledBeforeWaitingGetsNil() async {
65
+ let ready = ReadyValue<Int> { nil }
66
+ let waiter = Task {
67
+ withUnsafeCurrentTask { $0?.cancel() }
68
+ return await ready.wait()
69
+ }
70
+ #expect(await waiter.value == nil)
71
+ #expect(ready.waiterCount == 0)
72
+ }
73
+ }
@@ -59,8 +59,6 @@ struct RemoteCommandControllerTests {
59
59
 
60
60
  #expect(center.skipForwardCommand.isEnabled)
61
61
  #expect(center.skipBackwardCommand.isEnabled)
62
- #expect(controller.commandTargetPointers["skipForward"] != nil)
63
- #expect(controller.commandTargetPointers["skipBackward"] != nil)
64
62
  #expect(center.skipForwardCommand.preferredIntervals == [30])
65
63
  #expect(center.skipBackwardCommand.preferredIntervals == [30])
66
64
  }
@@ -73,7 +71,6 @@ struct RemoteCommandControllerTests {
73
71
  controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])])
74
72
 
75
73
  #expect(center.changePlaybackRateCommand.isEnabled)
76
- #expect(controller.commandTargetPointers["changePlaybackRate"] != nil)
77
74
  #expect(center.changePlaybackRateCommand.supportedPlaybackRates == [1.0, 1.5, 2.0])
78
75
  }
79
76
 
@@ -87,9 +84,7 @@ struct RemoteCommandControllerTests {
87
84
  controller.enable(commands: [.play, .pause])
88
85
 
89
86
  #expect(!center.nextTrackCommand.isEnabled)
90
- #expect(controller.commandTargetPointers["nextTrack"] == nil)
91
87
  #expect(center.playCommand.isEnabled)
92
- #expect(controller.commandTargetPointers["play"] != nil)
93
88
  }
94
89
 
95
90
  @Test func disableAllClearsEveryTarget() {
@@ -99,34 +94,23 @@ struct RemoteCommandControllerTests {
99
94
  controller.enable(commands: [.play, .pause, .skipForward(preferredIntervals: [15])])
100
95
  controller.disableAll()
101
96
 
102
- #expect(controller.commandTargetPointers.isEmpty)
103
97
  #expect(!center.playCommand.isEnabled)
104
98
  #expect(!center.pauseCommand.isEnabled)
105
99
  #expect(!center.skipForwardCommand.isEnabled)
106
100
  }
107
101
 
108
- /// Re-enabling the same set must not accumulate handlers: `enableRemoteCommand`
109
- /// removes the previous target before adding the new one.
110
- @Test func reEnablingKeepsOneTargetPerCommand() {
111
- let (controller, _) = makeController()
112
-
113
- controller.enable(commands: [.play, .pause])
114
- let first = controller.commandTargetPointers["play"]
115
- controller.enable(commands: [.play, .pause])
116
- let second = controller.commandTargetPointers["play"]
117
-
118
- #expect(controller.commandTargetPointers.count == 2)
119
- #expect(first as AnyObject? !== second as AnyObject?)
120
- }
121
-
122
- /// `setSkipAvailability` greys out next/previous when the queue has nowhere to
123
- /// go, and the state must survive a later `enable(commands:)`.
124
- @Test func skipAvailabilitySurvivesReEnable() {
102
+ /// Next/previous follow `skipAvailability` when refreshed and whenever they are enabled.
103
+ @Test func skipAvailabilityAppliesOnRefreshAndEnable() {
125
104
  let (controller, _) = makeController()
126
105
  let center = MPRemoteCommandCenter.shared()
106
+ var availability = (canNext: true, canPrevious: true)
107
+ controller.skipAvailability = { availability }
127
108
 
128
109
  controller.enable(commands: [.next, .previous])
129
- controller.setSkipAvailability(canNext: false, canPrevious: true)
110
+ #expect(center.nextTrackCommand.isEnabled)
111
+
112
+ availability = (false, true)
113
+ controller.refreshSkipAvailability()
130
114
  #expect(!center.nextTrackCommand.isEnabled)
131
115
  #expect(center.previousTrackCommand.isEnabled)
132
116
 
@@ -136,15 +120,12 @@ struct RemoteCommandControllerTests {
136
120
  #expect(center.previousTrackCommand.isEnabled)
137
121
  }
138
122
 
139
- /// The handler properties must not capture the controller strongly. Assigning a
140
- /// bare method reference (`= handlePlayCommandDefault`) curries to
141
- /// `{ [self] in self.method($0) }`, and storing that back on self is a permanent
142
- /// cycle — one leaked controller per player instance, across every JS reload.
123
+ /// The command targets must not capture the controller strongly, or every player
124
+ /// instance leaks one across JS reloads.
143
125
  @Test func doesNotRetainItselfThroughHandlers() {
144
126
  weak var weakController: RemoteCommandController?
145
127
  autoreleasepool {
146
128
  let (controller, _) = makeController()
147
- // Forces every lazy handler to initialize, which is what closes the cycle.
148
129
  controller.enable(commands: RemoteCommand.all())
149
130
  controller.disableAll()
150
131
  weakController = controller
@@ -167,6 +148,5 @@ struct RemoteCommandControllerTests {
167
148
  #expect(center.changeShuffleModeCommand.currentShuffleType == .items)
168
149
  #expect(center.changeRepeatModeCommand.currentRepeatType == .all)
169
150
  #expect(center.changeShuffleModeCommand.isEnabled)
170
- #expect(controller.commandTargetPointers["changeShuffleMode"] != nil)
171
151
  }
172
152
  }
@@ -48,10 +48,8 @@ struct RemoteCommandTests {
48
48
  #expect(toDisable.count == RemoteCommand.all().count)
49
49
  }
50
50
 
51
- /// `key` is the identity used both for the diff above and for the
52
- /// `commandTargetPointers` slot each command's handler lives in. Two cases
53
- /// sharing a key would silently share one slot: enabling one would remove the
54
- /// other's handler.
51
+ /// `key` is the identity the diff above compares; two cases sharing a key would
52
+ /// disable each other.
55
53
  @Test func keysAreUnique() {
56
54
  let all = RemoteCommand.all()
57
55
  #expect(Set(all.map(\.key)).count == all.count)