react-native-queue-player 1.0.7 → 1.0.9

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 (79) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +21 -3
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +30 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +9 -6
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +4 -1
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +48 -5
  6. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +55 -0
  7. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderArtworkTest.kt +72 -0
  8. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +21 -9
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +9 -9
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +1 -1
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +1 -1
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +3 -3
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +7 -7
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +6 -6
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -2
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +1 -1
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +1 -1
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +1 -1
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +2 -2
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +1 -1
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +1 -1
  24. package/ios/AVQueueBuilder.swift +29 -0
  25. package/ios/Cast/Core/CastNowPlayingController.swift +7 -4
  26. package/ios/NowPlayingInfo.swift +7 -9
  27. package/ios/PlaceholderArtwork.swift +24 -1
  28. package/ios/QueueState.swift +14 -2
  29. package/ios/Tests/AVQueueBuilderTests.swift +83 -9
  30. package/ios/Tests/CastNowPlayingControllerTests.swift +33 -0
  31. package/ios/Tests/PlaceholderArtworkTests.swift +81 -0
  32. package/ios/Tests/QueueStateTests.swift +50 -0
  33. package/ios/TrackPlayer.swift +126 -15
  34. package/lib/module/hooks/index.js +1 -0
  35. package/lib/module/hooks/index.js.map +1 -1
  36. package/lib/module/hooks/useQueue.js +57 -0
  37. package/lib/module/hooks/useQueue.js.map +1 -0
  38. package/lib/module/index.js.map +1 -1
  39. package/lib/module/types.js +7 -0
  40. package/lib/module/types.js.map +1 -1
  41. package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
  42. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  43. package/lib/typescript/hooks/index.d.ts +2 -0
  44. package/lib/typescript/hooks/index.d.ts.map +1 -1
  45. package/lib/typescript/hooks/useQueue.d.ts +20 -0
  46. package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
  47. package/lib/typescript/index.d.ts +1 -1
  48. package/lib/typescript/index.d.ts.map +1 -1
  49. package/lib/typescript/types.d.ts +22 -0
  50. package/lib/typescript/types.d.ts.map +1 -1
  51. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
  52. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
  53. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  54. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  55. package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
  60. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
  61. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
  62. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
  63. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  64. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  67. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
  68. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  69. package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
  70. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  71. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  72. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  73. package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
  74. package/package.json +1 -1
  75. package/src/TrackPlayer.nitro.ts +33 -0
  76. package/src/hooks/index.ts +3 -0
  77. package/src/hooks/useQueue.ts +56 -0
  78. package/src/index.ts +1 -0
  79. package/src/types.ts +29 -0
@@ -15,6 +15,10 @@ import AVFoundation
15
15
  /// overloads are adapters that unpack the Nitro structs and delegate here,
16
16
  /// so exercising the raw entry points is sufficient coverage for the
17
17
  /// merge + routing behaviour.
18
+ // Main-actor-isolated: several assertions read `AVPlayerItem.asset`, which
19
+ // the SDK marks main-actor-isolated; running the cases on the main actor
20
+ // keeps those reads warning-free.
21
+ @MainActor
18
22
  final class AVQueueBuilderTests: XCTestCase {
19
23
 
20
24
  // MARK: - httpOptionsRaw merging
@@ -165,7 +169,7 @@ final class AVQueueBuilderTests: XCTestCase {
165
169
  let item = AVQueueBuilder.makeItem(
166
170
  for: track,
167
171
  queueItemId: UUID().uuidString,
168
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
172
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
169
173
  cache: cache
170
174
  )
171
175
  XCTAssertNotNil(item)
@@ -182,7 +186,7 @@ final class AVQueueBuilderTests: XCTestCase {
182
186
  func testMakeItemFallsBackToRemoteWhenCacheMisses() {
183
187
  // No cache provided → behaves identically to the no-cache path.
184
188
  let track = makeTrack(id: "t1", url: "https://example.com/track.m4a")
185
- let config = PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
189
+ let config = PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
186
190
  let item = AVQueueBuilder.makeItem(
187
191
  for: track, queueItemId: UUID().uuidString, config: config, cache: nil)
188
192
  XCTAssertNotNil(item)
@@ -210,7 +214,7 @@ final class AVQueueBuilderTests: XCTestCase {
210
214
  let item = AVQueueBuilder.makeItem(
211
215
  for: track,
212
216
  queueItemId: UUID().uuidString,
213
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
217
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
214
218
  cache: cache
215
219
  )
216
220
  XCTAssertNotNil(item)
@@ -256,7 +260,7 @@ final class AVQueueBuilderTests: XCTestCase {
256
260
  let item = AVQueueBuilder.makeItem(
257
261
  for: track,
258
262
  queueItemId: UUID().uuidString,
259
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
263
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
260
264
  cache: cache
261
265
  )
262
266
  XCTAssertNotNil(item)
@@ -292,7 +296,7 @@ final class AVQueueBuilderTests: XCTestCase {
292
296
  let items = AVQueueBuilder.buildPlayerItems(
293
297
  tracks: [trackA, trackB],
294
298
  queueItemIds: [UUID().uuidString, UUID().uuidString],
295
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
299
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
296
300
  cache: cache
297
301
  )
298
302
  XCTAssertEqual(items.count, 2)
@@ -367,12 +371,12 @@ final class AVQueueBuilderTests: XCTestCase {
367
371
  let queueItemIdB = UUID().uuidString
368
372
  let itemA = AVQueueBuilder.makeItem(
369
373
  for: track, queueItemId: queueItemIdA,
370
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
374
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
371
375
  cache: nil
372
376
  )
373
377
  let itemB = AVQueueBuilder.makeItem(
374
378
  for: track, queueItemId: queueItemIdB,
375
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
379
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
376
380
  cache: nil
377
381
  )
378
382
  XCTAssertNotNil(itemA)
@@ -397,7 +401,7 @@ final class AVQueueBuilderTests: XCTestCase {
397
401
  let queueItemId = UUID().uuidString
398
402
  let item = AVQueueBuilder.makeItem(
399
403
  for: track, queueItemId: queueItemId,
400
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
404
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
401
405
  cache: nil
402
406
  )
403
407
  XCTAssertNotNil(item)
@@ -413,7 +417,7 @@ final class AVQueueBuilderTests: XCTestCase {
413
417
  let queueItemId = UUID().uuidString
414
418
  let item = AVQueueBuilder.makeItem(
415
419
  for: track, queueItemId: queueItemId,
416
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
420
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
417
421
  cache: nil
418
422
  )
419
423
  XCTAssertNotNil(item)
@@ -421,10 +425,80 @@ final class AVQueueBuilderTests: XCTestCase {
421
425
  "queueItemId must be set even when track.id is empty string")
422
426
  }
423
427
 
428
+ // MARK: - preloadAssetForInsert
429
+
430
+ func testPreloadAssetForInsertLoadsTracksForLocalFile() async throws {
431
+ let url = try Self.writeTinyWav()
432
+ defer { try? FileManager.default.removeItem(at: url) }
433
+ let item = try XCTUnwrap(
434
+ AVQueueBuilder.makeItemRaw(urlString: url.absoluteString, headers: nil, userAgent: nil))
435
+
436
+ await AVQueueBuilder.preloadAssetForInsert(item)
437
+
438
+ // The load resolved the tracks key on the same asset instance the
439
+ // enqueue will insert, so the audio track is present without a second
440
+ // fault — the preroll after insert has nothing left to load.
441
+ let tracks = try await item.asset.load(.tracks)
442
+ XCTAssertFalse(tracks.isEmpty,
443
+ "preload must resolve the local asset's tracks so the enqueue preroll is cheap")
444
+ }
445
+
446
+ func testPreloadAssetForInsertLeavesUnreadableAssetInsertable() async throws {
447
+ // Best-effort contract: a missing file's load fails but the item stays
448
+ // intact + insertable, so the enqueue proceeds unwarmed rather than
449
+ // being blocked or corrupted.
450
+ let missing = FileManager.default.temporaryDirectory
451
+ .appendingPathComponent("does-not-exist-\(UUID().uuidString).m4a")
452
+ let item = try XCTUnwrap(
453
+ AVQueueBuilder.makeItemRaw(urlString: missing.absoluteString, headers: nil, userAgent: nil))
454
+
455
+ let start = Date()
456
+ await AVQueueBuilder.preloadAssetForInsert(item, timeout: 0.5)
457
+ let elapsed = Date().timeIntervalSince(start)
458
+
459
+ XCTAssertLessThan(elapsed, 2.0,
460
+ "a best-effort preload of an unreadable asset must return promptly, not hang")
461
+ let urlAsset = try XCTUnwrap(item.asset as? AVURLAsset)
462
+ XCTAssertEqual(urlAsset.url, missing,
463
+ "a failed preload must leave the item's asset unchanged and insertable")
464
+ }
465
+
424
466
  private func makeTrack(id: String, url: String) -> TrackItem {
425
467
  return TrackItem(
426
468
  id: id, url: url, title: nil, artist: nil, album: nil,
427
469
  duration: nil, artworkUrl: nil, extras: nil
428
470
  )
429
471
  }
472
+
473
+ /// Writes a minimal valid 16-bit PCM mono WAV (silent samples) so
474
+ /// AVFoundation resolves exactly one audio track from the file.
475
+ private static func writeTinyWav() throws -> URL {
476
+ let sampleRate: UInt32 = 44100
477
+ let channels: UInt16 = 1
478
+ let bitsPerSample: UInt16 = 16
479
+ let frameCount = 1024
480
+ let dataSize = UInt32(frameCount) * UInt32(channels) * UInt32(bitsPerSample / 8)
481
+ var data = Data()
482
+ func appendLE<T: FixedWidthInteger>(_ value: T) {
483
+ withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
484
+ }
485
+ data.append(contentsOf: Array("RIFF".utf8))
486
+ appendLE(UInt32(36) + dataSize)
487
+ data.append(contentsOf: Array("WAVE".utf8))
488
+ data.append(contentsOf: Array("fmt ".utf8))
489
+ appendLE(UInt32(16))
490
+ appendLE(UInt16(1))
491
+ appendLE(channels)
492
+ appendLE(sampleRate)
493
+ appendLE(sampleRate * UInt32(channels) * UInt32(bitsPerSample / 8))
494
+ appendLE(UInt16(channels * bitsPerSample / 8))
495
+ appendLE(bitsPerSample)
496
+ data.append(contentsOf: Array("data".utf8))
497
+ appendLE(dataSize)
498
+ data.append(Data(count: Int(dataSize)))
499
+ let url = FileManager.default.temporaryDirectory
500
+ .appendingPathComponent("preload-\(UUID().uuidString).wav")
501
+ try data.write(to: url)
502
+ return url
503
+ }
430
504
  }
@@ -132,6 +132,39 @@ final class CastNowPlayingControllerTests: XCTestCase {
132
132
  "activation renders the most recent cached metadata"
133
133
  )
134
134
  }
135
+
136
+ func testSeededPlaceholderReflectsConfiguredCustomImage() throws {
137
+ let tmp = FileManager.default.temporaryDirectory
138
+ .appendingPathComponent("rnqp-cast-placeholder-\(UUID().uuidString).jpg")
139
+ try makeTinyJpeg().write(to: tmp)
140
+ defer {
141
+ try? FileManager.default.removeItem(at: tmp)
142
+ PlaceholderArtwork.setCustom(uri: nil)
143
+ }
144
+ let builtInSize = PlaceholderArtwork.image.size
145
+ PlaceholderArtwork.setCustom(uri: tmp.absoluteString)
146
+ let customSize = PlaceholderArtwork.image.size
147
+ // Precondition: the fixture is a different size from the built-in,
148
+ // else the size check below can't tell them apart.
149
+ XCTAssertNotEqual(customSize, builtInSize)
150
+
151
+ try router.setActive(ControllableRemoteSession())
152
+
153
+ let artwork = info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork
154
+ XCTAssertEqual(
155
+ artwork?.image(at: customSize)?.size, customSize,
156
+ "the cast lockscreen seeds the live custom placeholder, not a frozen built-in"
157
+ )
158
+ }
159
+
160
+ private func makeTinyJpeg() -> Data {
161
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
162
+ let image = renderer.image { ctx in
163
+ UIColor.green.setFill()
164
+ ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
165
+ }
166
+ return image.jpegData(compressionQuality: 1.0) ?? Data()
167
+ }
135
168
  }
136
169
 
137
170
  // MARK: - Listener-firing fake
@@ -0,0 +1,81 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+ import UIKit
4
+
5
+ /// `PlaceholderArtwork` swaps in a consumer-supplied cover-art placeholder
6
+ /// (`PlayerConfig.placeholderArtworkUri`) in place of the bundled RNQP
7
+ /// image. Only a local `file://` image is honoured; a remote or unset URI,
8
+ /// or an unloadable file, keeps the bundled placeholder.
9
+ @MainActor
10
+ final class PlaceholderArtworkTests: XCTestCase {
11
+
12
+ override func tearDown() {
13
+ // Process-wide singleton — reset so a set custom image does not leak
14
+ // into other suites that read the bundled placeholder.
15
+ PlaceholderArtwork.setCustom(uri: nil)
16
+ super.tearDown()
17
+ }
18
+
19
+ func testCustomFileUriReplacesTheBundledPlaceholder() throws {
20
+ let bundled = PlaceholderArtwork.image
21
+ let tmp = FileManager.default.temporaryDirectory
22
+ .appendingPathComponent("rnqp-placeholder-\(UUID().uuidString).jpg")
23
+ try makeJpegBytes().write(to: tmp)
24
+ defer { try? FileManager.default.removeItem(at: tmp) }
25
+
26
+ PlaceholderArtwork.setCustom(uri: tmp.absoluteString)
27
+
28
+ let image = PlaceholderArtwork.image
29
+ XCTAssertFalse(image === bundled,
30
+ "a loadable file:// image replaces the bundled placeholder")
31
+ XCTAssertTrue(image === PlaceholderArtwork.image,
32
+ "the custom image is stable across reads")
33
+ }
34
+
35
+ func testUnsetUriKeepsTheBundledPlaceholder() {
36
+ let bundled = PlaceholderArtwork.image
37
+ PlaceholderArtwork.setCustom(uri: nil)
38
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
39
+ }
40
+
41
+ func testNonFileUriIsRejectedAndKeepsTheBundledPlaceholder() {
42
+ let bundled = PlaceholderArtwork.image
43
+ PlaceholderArtwork.setCustom(uri: "https://example.com/logo.png")
44
+ XCTAssertTrue(PlaceholderArtwork.image === bundled,
45
+ "a remote URI is rejected — offline-only file:// contract")
46
+ }
47
+
48
+ func testUnloadableFileUriFallsBackToTheBundledPlaceholder() {
49
+ let bundled = PlaceholderArtwork.image
50
+ PlaceholderArtwork.setCustom(uri: "file:///nonexistent/path/missing.jpg")
51
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
52
+ }
53
+
54
+ func testEmptyUriKeepsTheBundledPlaceholder() {
55
+ let bundled = PlaceholderArtwork.image
56
+ PlaceholderArtwork.setCustom(uri: "")
57
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
58
+ }
59
+
60
+ func testUppercaseFileSchemeIsHonoured() throws {
61
+ let bundled = PlaceholderArtwork.image
62
+ let tmp = FileManager.default.temporaryDirectory
63
+ .appendingPathComponent("rnqp-placeholder-\(UUID().uuidString).jpg")
64
+ try makeJpegBytes().write(to: tmp)
65
+ defer { try? FileManager.default.removeItem(at: tmp) }
66
+
67
+ PlaceholderArtwork.setCustom(uri: "FILE://" + tmp.path)
68
+
69
+ XCTAssertFalse(PlaceholderArtwork.image === bundled,
70
+ "an uppercase FILE:// scheme is honoured (case-insensitive)")
71
+ }
72
+
73
+ private func makeJpegBytes() -> Data {
74
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
75
+ let image = renderer.image { ctx in
76
+ UIColor.blue.setFill()
77
+ ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
78
+ }
79
+ return image.jpegData(compressionQuality: 1.0) ?? Data()
80
+ }
81
+ }
@@ -0,0 +1,50 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// `QueueState` holds `tracks` and `queueItemIds` as parallel arrays. The
5
+ /// id-supplying `insert` overload lets a caller reuse the lib-generated
6
+ /// ids it stamped on pre-built AVPlayerItems (the immediate-next preload
7
+ /// path) while preserving the 1:1 invariant.
8
+ final class QueueStateTests: XCTestCase {
9
+
10
+ func testInsertWithSuppliedIdsPlacesThemParallelToTracks() {
11
+ var state = QueueState()
12
+ _ = state.replaceAll([track("a"), track("b"), track("c")])
13
+
14
+ state.insert([track("x"), track("y")], at: 1, ids: ["id-x", "id-y"])
15
+
16
+ XCTAssertEqual(state.tracks.map { $0.url }, ["a", "x", "y", "b", "c"])
17
+ XCTAssertEqual(state.queueItemIds.count, state.tracks.count)
18
+ XCTAssertEqual(state.queueItemIds[1], "id-x")
19
+ XCTAssertEqual(state.queueItemIds[2], "id-y")
20
+ }
21
+
22
+ func testInsertWithSuppliedIdsAppendsAtEnd() {
23
+ var state = QueueState()
24
+ _ = state.replaceAll([track("a")])
25
+
26
+ state.insert([track("z")], at: 1, ids: ["id-z"])
27
+
28
+ XCTAssertEqual(state.tracks.map { $0.url }, ["a", "z"])
29
+ XCTAssertEqual(state.queueItemIds.count, 2)
30
+ XCTAssertEqual(state.queueItemIds[1], "id-z")
31
+ }
32
+
33
+ func testGeneratingInsertStillReturnsFreshParallelIds() {
34
+ var state = QueueState()
35
+ _ = state.replaceAll([track("a"), track("b")])
36
+
37
+ let ids = state.insert([track("x")], at: 1)
38
+
39
+ XCTAssertEqual(ids.count, 1)
40
+ XCTAssertEqual(state.queueItemIds[1], ids[0])
41
+ XCTAssertEqual(state.queueItemIds.count, state.tracks.count)
42
+ }
43
+
44
+ private func track(_ url: String) -> TrackItem {
45
+ TrackItem(
46
+ id: nil, url: url, title: nil, artist: nil, album: nil,
47
+ duration: nil, artworkUrl: nil, extras: nil
48
+ )
49
+ }
50
+ }
@@ -97,7 +97,7 @@ class TrackPlayer: HybridTrackPlayerSpec {
97
97
  PlayerConfig(
98
98
  httpHeaders: nil,
99
99
  userAgent: nil,
100
- autoRetries: nil,
100
+ placeholderArtworkUri: nil, autoRetries: nil,
101
101
  retryBackoffMs: nil,
102
102
  networkTimeoutMs: nil,
103
103
  audioContentType: nil,
@@ -232,6 +232,7 @@ class TrackPlayer: HybridTrackPlayerSpec {
232
232
  // simultaneously without clobbering each other.
233
233
  private let stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Void>()
234
234
  private let trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Void>()
235
+ private let queueChangeListeners = ListenerRegistry<([TrackItem], Double, QueueChangeReason) -> Void>()
235
236
  private let progressListeners = ListenerRegistry<(PlayerProgress) -> Void>()
236
237
  private let milestoneListeners = ListenerRegistry<(Double, Double) -> Void>()
237
238
  /// Per-playthrough 25/50/75/90% milestone state. Main-thread-confined
@@ -608,6 +609,10 @@ class TrackPlayer: HybridTrackPlayerSpec {
608
609
  // each track-change resolve.
609
610
  self.artworkResolver.configHeaders = config.httpHeaders
610
611
  self.artworkResolver.configUserAgent = config.userAgent
612
+ // Swap the cover-art placeholder to the consumer-supplied image
613
+ // (or back to the built-in when unset). Every placeholder site
614
+ // reads `PlaceholderArtwork.image`, so this is the only hook needed.
615
+ PlaceholderArtwork.setCustom(uri: config.placeholderArtworkUri)
611
616
  // Same live config for the cast lockscreen's own artwork
612
617
  // resolver so authenticated covers resolve during cast.
613
618
  CastNowPlayingController.shared.configureArtwork(
@@ -1372,6 +1377,7 @@ class TrackPlayer: HybridTrackPlayerSpec {
1372
1377
  self.castEventBridge.seedStartMetadata(startIndex: resolvedStart)
1373
1378
  self.rescheduleLookahead()
1374
1379
  self.recomputeCapabilities()
1380
+ self.emitQueueChange(.setQueue)
1375
1381
  }
1376
1382
  return
1377
1383
  }
@@ -1415,6 +1421,7 @@ class TrackPlayer: HybridTrackPlayerSpec {
1415
1421
  }
1416
1422
  self.rescheduleLookahead()
1417
1423
  self.recomputeCapabilities()
1424
+ self.emitQueueChange(.setQueue)
1418
1425
  }
1419
1426
  }
1420
1427
  }
@@ -1433,30 +1440,86 @@ class TrackPlayer: HybridTrackPlayerSpec {
1433
1440
  // resolved insert index against the pre-mutation queue, then mirror
1434
1441
  // the insert to the receiver. Index resolution to receiver itemIds
1435
1442
  // happens inside the cast session via GCKMediaQueue.
1436
- let insertAt: Int = self.onMainSync {
1437
- let at = QueueMutationArithmetic.clampInsertBefore(
1438
- insertBefore: insertBefore.flatMap { $0.isFinite ? Int($0) : nil },
1439
- trackCount: self.tracks.count
1440
- )
1441
- self.performingMutation {
1442
- self.addToQueueBody(newTracks: newTracks, insertBefore: insertBefore)
1443
+ //
1444
+ // When the insert lands in the immediate-next slot of a playing
1445
+ // queue, `AVQueuePlayer` prerolls the new item on the shared render
1446
+ // pipeline mid-playback, which briefly disturbs the current item.
1447
+ // For that case only, build the items on main but preload the
1448
+ // leading asset OFF the mutation, then commit state + engine insert
1449
+ // atomically so the forced preroll is cheap. Every other insert
1450
+ // keeps the synchronous path — the tail is inert (no preroll, no
1451
+ // disturbance), so there's nothing to warm.
1452
+ let plan: (insertAt: Int, prebuild: (ids: [String], items: [AVPlayerItem])?) =
1453
+ self.onMainSync {
1454
+ let at = QueueMutationArithmetic.clampInsertBefore(
1455
+ insertBefore: insertBefore.flatMap { $0.isFinite ? Int($0) : nil },
1456
+ trackCount: self.tracks.count
1457
+ )
1458
+ // Immediate-next slot = the only insert AVQueuePlayer prerolls
1459
+ // while the current item plays (covers "play next" mid-queue and
1460
+ // an append when the current track is the last one).
1461
+ let oldCur = self.currentTrackIndex
1462
+ guard oldCur >= 0, at == oldCur + 1 else {
1463
+ self.performingMutation {
1464
+ self.addToQueueBody(newTracks: newTracks, insertBefore: insertBefore)
1465
+ }
1466
+ self.rescheduleLookahead()
1467
+ self.recomputeCapabilities()
1468
+ return (at, nil)
1469
+ }
1470
+ let ids = newTracks.map { _ in UUID().uuidString }
1471
+ let items = AVQueueBuilder.buildPlayerItems(
1472
+ tracks: newTracks, queueItemIds: ids,
1473
+ config: self.config, cache: self.lookaheadCache)
1474
+ return (at, (ids: ids, items: items))
1475
+ }
1476
+
1477
+ var insertAt = plan.insertAt
1478
+ if let prebuild = plan.prebuild {
1479
+ // Warm the leading buildable item's asset off the mutation so the
1480
+ // immediate-next preroll is cheap. `.first` is the first item that
1481
+ // survived `buildPlayerItems`' compactMap — the same item that lands
1482
+ // in the engine's next slot even if an earlier track had an
1483
+ // unbuildable URL.
1484
+ if let leading = prebuild.items.first {
1485
+ await AVQueueBuilder.preloadAssetForInsert(leading)
1486
+ }
1487
+ // Commit state + engine insert atomically after the preload, and
1488
+ // route the cast mirror against the index the local queue actually
1489
+ // used: a mutation during the preload can shift it, and `at` is
1490
+ // resolved against the live queue inside the same hop.
1491
+ insertAt = self.onMainSync {
1492
+ let committedAt = QueueMutationArithmetic.clampInsertBefore(
1493
+ insertBefore: insertBefore.flatMap { $0.isFinite ? Int($0) : nil },
1494
+ trackCount: self.tracks.count
1495
+ )
1496
+ self.performingMutation {
1497
+ self.addToQueueBody(
1498
+ newTracks: newTracks, insertBefore: insertBefore, prebuilt: prebuild)
1499
+ }
1500
+ self.rescheduleLookahead()
1501
+ self.recomputeCapabilities()
1502
+ return committedAt
1443
1503
  }
1444
- self.rescheduleLookahead()
1445
- self.recomputeCapabilities()
1446
- return at
1447
1504
  }
1505
+
1448
1506
  // Mirror only when every new track is castable. A dropped item would
1449
1507
  // shift the receiver queue out of alignment with the local index space
1450
1508
  // that later mutations (remove/move) resolve against, so a partial set
1451
1509
  // is not routed at all — the same all-or-nothing rule `setQueue` applies.
1452
1510
  let castItems = newTracks.compactMap { CastMediaItem.from(track: $0) }
1453
1511
  if !castItems.isEmpty, castItems.count == newTracks.count {
1454
- _ = await CastTransportRouter.routeAddToQueue(items: castItems, beforeIndex: insertAt)
1512
+ _ = await CastTransportRouter.routeAddToQueue(
1513
+ items: castItems, beforeIndex: insertAt)
1455
1514
  }
1456
1515
  }
1457
1516
  }
1458
1517
 
1459
- private func addToQueueBody(newTracks: [TrackItem], insertBefore: Double?) {
1518
+ private func addToQueueBody(
1519
+ newTracks: [TrackItem],
1520
+ insertBefore: Double?,
1521
+ prebuilt: (ids: [String], items: [AVPlayerItem])? = nil
1522
+ ) {
1460
1523
  guard !newTracks.isEmpty, let engine = self.engine else { return }
1461
1524
 
1462
1525
  // Non-finite `insertBefore` is dropped (treated as append) so
@@ -1468,7 +1531,18 @@ class TrackPlayer: HybridTrackPlayerSpec {
1468
1531
  trackCount: self.tracks.count
1469
1532
  )
1470
1533
 
1471
- let newIds = self.queueState.insert(newTracks, at: at)
1534
+ // When the caller pre-built the items (immediate-next preload path)
1535
+ // reuse its lib-generated ids so each item's stamped `queueItemId`
1536
+ // stays 1:1 with `queueItemIds`. `at` is resolved against the live
1537
+ // queue here, so the reused items land at the position the current
1538
+ // queue dictates.
1539
+ let newIds: [String]
1540
+ if let prebuilt = prebuilt {
1541
+ self.queueState.insert(newTracks, at: at, ids: prebuilt.ids)
1542
+ newIds = prebuilt.ids
1543
+ } else {
1544
+ newIds = self.queueState.insert(newTracks, at: at)
1545
+ }
1472
1546
  // Initialise retry-budget entries for the new items so they have
1473
1547
  // an auto-retry budget when (if) they hit a transient error.
1474
1548
  let attempts = self.effectiveAutoRetries()
@@ -1483,6 +1557,10 @@ class TrackPlayer: HybridTrackPlayerSpec {
1483
1557
  self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
1484
1558
  currentIndex: oldCur, insertAt: at, count: newTracks.count
1485
1559
  )
1560
+ // Queue data model is final here (tracks + index committed); the
1561
+ // engine insert below only touches the audio pipeline. Emit before
1562
+ // the early returns so every non-empty add reports one snapshot.
1563
+ self.emitQueueChange(.add)
1486
1564
 
1487
1565
  if oldCur < 0 {
1488
1566
  // Queue was empty — player stays empty until skipToIndex.
@@ -1510,7 +1588,7 @@ class TrackPlayer: HybridTrackPlayerSpec {
1510
1588
  existing.first { $0.queueItemId != nil && $0.queueItemId == anchorId }
1511
1589
  ?? existing.last
1512
1590
 
1513
- let newItems = AVQueueBuilder.buildPlayerItems(
1591
+ let newItems = prebuilt?.items ?? AVQueueBuilder.buildPlayerItems(
1514
1592
  tracks: newTracks, queueItemIds: newIds,
1515
1593
  config: self.config, cache: self.lookaheadCache)
1516
1594
  let ok = engine.insertItems(newItems, after: afterItem)
@@ -1596,6 +1674,10 @@ class TrackPlayer: HybridTrackPlayerSpec {
1596
1674
  sanitisedIndices: sanitised,
1597
1675
  trackCountBefore: trackCountBefore
1598
1676
  )
1677
+ // Reached only past the `sanitised.isEmpty` guard, so ≥1 track was
1678
+ // removed. tracks + index are final; the engine.remove loop below
1679
+ // only detaches the AVPlayerItems.
1680
+ self.emitQueueChange(.remove)
1599
1681
 
1600
1682
  // The currentItem is untouched (never in `itemsToRemove`); removing
1601
1683
  // only later/earlier items leaves it playing in place.
@@ -1665,11 +1747,15 @@ class TrackPlayer: HybridTrackPlayerSpec {
1665
1747
  self.performingMutation {
1666
1748
  self.syncPlayerQueue(preserveCurrent: preserveCurrent)
1667
1749
  }
1750
+ // Reached only past the invalid/`from == to` guard, so the order
1751
+ // actually changed.
1752
+ self.emitQueueChange(.move)
1668
1753
  }
1669
1754
 
1670
1755
  func clearQueue() throws -> Promise<Void> {
1671
1756
  return Promise<Void>.async {
1672
1757
  await self.onMain {
1758
+ let queueWasNonEmpty = !self.tracks.isEmpty
1673
1759
  self.performingMutation {
1674
1760
  self.engine?.removeAllItems()
1675
1761
  self.queueState.clear()
@@ -1691,6 +1777,11 @@ class TrackPlayer: HybridTrackPlayerSpec {
1691
1777
  self.lastErrorQueueItemId = nil
1692
1778
  self.lastErrorCode = nil
1693
1779
  self.retryAttemptsRemaining.removeAll()
1780
+ // Fire queue-change BEFORE the synthetic track-change so a
1781
+ // `useQueue()` consumer sees the empty queue + index -1 together,
1782
+ // not a one-frame (old queue, index -1) transient. Only when the
1783
+ // queue was actually non-empty — clearing an empty queue is a no-op.
1784
+ if queueWasNonEmpty { self.emitQueueChange(.clear) }
1694
1785
  self.trackChangeListeners.forEach { $0(nil, Double(-1), .queueReplaced, nil) }
1695
1786
  self.nowPlayingInfo.refreshAll()
1696
1787
  self.artworkResolver.cancelInFlight()
@@ -2823,6 +2914,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
2823
2914
  }
2824
2915
  self.rescheduleLookahead()
2825
2916
  self.recomputeCapabilities()
2917
+ // Skip a single-track shuffle — the order can't change.
2918
+ if self.tracks.count > 1 { self.emitQueueChange(.shuffle) }
2826
2919
 
2827
2920
  // Resume playback if the user was playing before the shuffle.
2828
2921
  // Stays paused otherwise — consumer can call play() if they
@@ -2960,6 +3053,24 @@ class TrackPlayer: HybridTrackPlayerSpec {
2960
3053
  return { [weak self] in self?.trackChangeListeners.remove(id: id) }
2961
3054
  }
2962
3055
 
3056
+ func onQueueChange(
3057
+ callback: @escaping (_ queue: [TrackItem], _ currentIndex: Double, _ reason: QueueChangeReason) -> Void
3058
+ ) throws -> () -> Void {
3059
+ let id = queueChangeListeners.add(callback)
3060
+ return { [weak self] in self?.queueChangeListeners.remove(id: id) }
3061
+ }
3062
+
3063
+ /// Fire `onQueueChange` with an atomic snapshot of the post-mutation
3064
+ /// queue + current index. Main-thread only — every queue mutation
3065
+ /// commits on main, so `self.tracks` / `self.currentTrackIndex` are the
3066
+ /// settled values here. Callers gate this on an actual content/order
3067
+ /// change so a no-op mutation never emits.
3068
+ private func emitQueueChange(_ reason: QueueChangeReason) {
3069
+ let snapshot = self.tracks
3070
+ let index = Double(self.currentTrackIndex)
3071
+ queueChangeListeners.forEach { $0(snapshot, index, reason) }
3072
+ }
3073
+
2963
3074
  func onProgress(
2964
3075
  callback: @escaping (_ progress: PlayerProgress) -> Void
2965
3076
  ) throws -> () -> Void {
@@ -3,6 +3,7 @@
3
3
  export { useProgress } from "./useProgress.js";
4
4
  export { usePlaybackState } from "./usePlaybackState.js";
5
5
  export { useActiveTrack } from "./useActiveTrack.js";
6
+ export { useQueue } from "./useQueue.js";
6
7
  export { useCanSkip } from "./useCanSkip.js";
7
8
  export { useSleepTimer } from "./useSleepTimer.js";
8
9
  export { useBufferState } from "./useBufferState.js";
@@ -1 +1 @@
1
- {"version":3,"names":["useProgress","usePlaybackState","useActiveTrack","useCanSkip","useSleepTimer","useBufferState","useFullyBuffered","useEqualizer","useVisualizerState","useLookaheadCache","useNowPlayingFormat","useCast","useAudioRoute"],"sourceRoot":"../../../src","sources":["hooks/index.ts"],"mappings":";;AAAA,SAASA,WAAW,QAAQ,kBAAe;AAG3C,SAASC,gBAAgB,QAAQ,uBAAoB;AAGrD,SAASC,cAAc,QAAQ,qBAAkB;AAGjD,SAASC,UAAU,QAAQ,iBAAc;AAGzC,SAASC,aAAa,QAAQ,oBAAiB;AAE/C,SAASC,cAAc,QAAQ,qBAAkB;AAEjD,SAASC,gBAAgB,QAAQ,uBAAoB;AAErD,SAASC,YAAY,QAAQ,mBAAgB;AAG7C,SAASC,kBAAkB,QAAQ,yBAAsB;AAGzD,SAASC,iBAAiB,QAAQ,wBAAqB;AAGvD,SAASC,mBAAmB,QAAQ,0BAAuB;AAE3D,SAASC,OAAO,QAAQ,cAAW;AAGnC,SAASC,aAAa,QAAQ,oBAAiB","ignoreList":[]}
1
+ {"version":3,"names":["useProgress","usePlaybackState","useActiveTrack","useQueue","useCanSkip","useSleepTimer","useBufferState","useFullyBuffered","useEqualizer","useVisualizerState","useLookaheadCache","useNowPlayingFormat","useCast","useAudioRoute"],"sourceRoot":"../../../src","sources":["hooks/index.ts"],"mappings":";;AAAA,SAASA,WAAW,QAAQ,kBAAe;AAG3C,SAASC,gBAAgB,QAAQ,uBAAoB;AAGrD,SAASC,cAAc,QAAQ,qBAAkB;AAGjD,SAASC,QAAQ,QAAQ,eAAY;AAGrC,SAASC,UAAU,QAAQ,iBAAc;AAGzC,SAASC,aAAa,QAAQ,oBAAiB;AAE/C,SAASC,cAAc,QAAQ,qBAAkB;AAEjD,SAASC,gBAAgB,QAAQ,uBAAoB;AAErD,SAASC,YAAY,QAAQ,mBAAgB;AAG7C,SAASC,kBAAkB,QAAQ,yBAAsB;AAGzD,SAASC,iBAAiB,QAAQ,wBAAqB;AAGvD,SAASC,mBAAmB,QAAQ,0BAAuB;AAE3D,SAASC,OAAO,QAAQ,cAAW;AAGnC,SAASC,aAAa,QAAQ,oBAAiB","ignoreList":[]}