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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +21 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +9 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +4 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +48 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +55 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderArtworkTest.kt +72 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +21 -9
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +9 -9
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +3 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +7 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +6 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +1 -1
- package/ios/AVQueueBuilder.swift +29 -0
- package/ios/Cast/Core/CastNowPlayingController.swift +7 -4
- package/ios/NowPlayingInfo.swift +7 -9
- package/ios/PlaceholderArtwork.swift +24 -1
- package/ios/QueueState.swift +14 -2
- package/ios/Tests/AVQueueBuilderTests.swift +83 -9
- package/ios/Tests/CastNowPlayingControllerTests.swift +33 -0
- package/ios/Tests/PlaceholderArtworkTests.swift +81 -0
- package/ios/Tests/QueueStateTests.swift +50 -0
- package/ios/TrackPlayer.swift +126 -15
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/useQueue.js +57 -0
- package/lib/module/hooks/useQueue.js.map +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +7 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/index.d.ts +2 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +20 -0
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +22 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
- package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
- package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
- package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
- package/package.json +1 -1
- package/src/TrackPlayer.nitro.ts +33 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useQueue.ts +56 -0
- package/src/index.ts +1 -0
- 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
|
+
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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(
|
|
1512
|
+
_ = await CastTransportRouter.routeAddToQueue(
|
|
1513
|
+
items: castItems, beforeIndex: insertAt)
|
|
1455
1514
|
}
|
|
1456
1515
|
}
|
|
1457
1516
|
}
|
|
1458
1517
|
|
|
1459
|
-
private func addToQueueBody(
|
|
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
|
-
|
|
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":[]}
|