@rntp/player 5.0.0-beta.4 → 5.0.0-beta.6

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 (56) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +47 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +47 -35
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +70 -22
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +24 -3
  56. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -5,110 +5,41 @@
5
5
 
6
6
  import Foundation
7
7
 
8
- final class Preloader: NSObject, URLSessionDataDelegate {
9
-
8
+ final class Preloader {
9
+ private let coordinator: DownloadCoordinator
10
10
  private let cache: AudioCache
11
- private lazy var session: URLSession = {
12
- let config = URLSessionConfiguration.default
13
- config.networkServiceType = .background
14
- let queue = OperationQueue()
15
- queue.maxConcurrentOperationCount = 1
16
- return URLSession(configuration: config, delegate: self, delegateQueue: queue)
17
- }()
18
-
19
- private var activeKey: String?
20
- private var activeTask: URLSessionDataTask?
21
- private var needsContentInfo: Bool = false
11
+ private var activeTokens: [String: DownloadToken] = [:]
22
12
 
23
- init(cache: AudioCache) {
13
+ init(cache: AudioCache, coordinator: DownloadCoordinator) {
24
14
  self.cache = cache
15
+ self.coordinator = coordinator
25
16
  }
26
17
 
27
- // MARK: - Public
28
-
29
- /// Begin preloading the given URL. Cancels any in-flight preload.
30
- func preload(url: URL, headers: [String: String]?) {
18
+ /// Preload a URL. If maxBytes is nil, downloads the entire file.
19
+ func preload(url: URL, headers: [String: String]?, maxBytes: Int64? = nil) {
31
20
  let key = cache.cacheKey(for: url)
32
-
33
21
  if cache.isFullyCached(key: key) { return }
34
- if key == activeKey { return }
35
-
36
- cancel()
37
- activeKey = key
22
+ if activeTokens[key] != nil { return }
38
23
 
39
- var request = URLRequest(url: url)
40
- headers?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
41
-
42
- let cached = cache.cachedBytes(for: key)
43
- if cached > 0 {
44
- request.setValue("bytes=\(cached)-", forHTTPHeaderField: "Range")
45
- needsContentInfo = false
46
- } else {
47
- needsContentInfo = (cache.contentInfo(for: key) == nil)
24
+ let token = coordinator.download(url: url, headers: headers, cacheKey: key, maxBytes: maxBytes) { [weak self] _ in
25
+ self?.activeTokens.removeValue(forKey: key)
48
26
  }
49
-
50
- activeTask = session.dataTask(with: request)
51
- activeTask?.resume()
27
+ activeTokens[key] = token
52
28
  }
53
29
 
54
- func cancel() {
55
- activeTask?.cancel()
56
- activeTask = nil
57
- activeKey = nil
58
- }
59
-
60
- // MARK: - URLSessionDataDelegate
61
-
62
- func urlSession(
63
- _ session: URLSession,
64
- dataTask: URLSessionDataTask,
65
- didReceive response: URLResponse,
66
- completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
67
- ) {
68
- guard let key = activeKey else {
69
- completionHandler(.cancel)
70
- return
71
- }
72
-
73
- if needsContentInfo, let http = response as? HTTPURLResponse {
74
- let mime = http.mimeType ?? "audio/mpeg"
75
- var totalLength: Int64 = -1
76
-
77
- if http.statusCode == 206, let rangeHeader = http.value(forHTTPHeaderField: "Content-Range") {
78
- if let slashIdx = rangeHeader.lastIndex(of: "/") {
79
- totalLength = Int64(rangeHeader[rangeHeader.index(after: slashIdx)...]) ?? -1
80
- }
81
- } else {
82
- totalLength = response.expectedContentLength
83
- }
84
-
85
- let byteRange = http.statusCode == 206
86
- let info = AudioCache.ContentInfo(
87
- contentType: mime,
88
- contentLength: totalLength,
89
- isByteRangeAccessSupported: byteRange
90
- )
91
- cache.storeContentInfo(info, for: key, url: dataTask.originalRequest?.url ?? URL(fileURLWithPath: "/"))
92
- needsContentInfo = false
30
+ /// Cancel preload for a specific URL.
31
+ func cancel(url: URL) {
32
+ let key = cache.cacheKey(for: url)
33
+ if let token = activeTokens.removeValue(forKey: key) {
34
+ coordinator.cancelDownload(token: token)
93
35
  }
94
-
95
- completionHandler(.allow)
96
- }
97
-
98
- func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
99
- guard let key = activeKey else { return }
100
- cache.appendData(data, for: key)
101
36
  }
102
37
 
103
- func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
104
- guard let key = activeKey else { return }
105
-
106
- if error == nil {
107
- cache.touchAccessDate(for: key)
108
- cache.evictIfNeeded()
38
+ /// Cancel all active preloads.
39
+ func cancelAll() {
40
+ for token in activeTokens.values {
41
+ coordinator.cancelDownload(token: token)
109
42
  }
110
-
111
- activeTask = nil
112
- activeKey = nil
43
+ activeTokens.removeAll()
113
44
  }
114
45
  }
@@ -0,0 +1,147 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import Foundation
7
+
8
+ /// Protocol for the minimal player interface the sleep timer needs.
9
+ protocol SleepTimerPlayer: AnyObject {
10
+ var volume: Float { get set }
11
+ var currentIndex: Int { get }
12
+ func pause()
13
+ }
14
+
15
+ /// Makes AudioPlayer usable as a SleepTimerPlayer.
16
+ extension AudioPlayer: SleepTimerPlayer {}
17
+
18
+ class SleepTimerController {
19
+
20
+ /// Called when the sleep timer fires (pauses playback).
21
+ var onTriggered: ((_ type: String) -> Void)?
22
+
23
+ private weak var player: SleepTimerPlayer?
24
+
25
+ private(set) var sleepTimerType: String?
26
+ private(set) var sleepTimerRemainingSeconds: Double = 0
27
+ private(set) var sleepTimerFadeOutSeconds: Double = 0
28
+ private(set) var sleepTimerTargetIndex: Int?
29
+ private(set) var sleepTimerPreviousIndex: Int?
30
+ private var sleepTimer: Timer?
31
+ private var sleepTimerPreFadeVolume: Float?
32
+
33
+ init(player: SleepTimerPlayer) {
34
+ self.player = player
35
+ }
36
+
37
+ // MARK: - Public API
38
+
39
+ func sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
40
+ cancelInternal(restoreVolume: true)
41
+ sleepTimerType = "time"
42
+ sleepTimerRemainingSeconds = seconds
43
+ sleepTimerFadeOutSeconds = min(fadeOutSeconds, seconds)
44
+
45
+ if seconds <= 0 {
46
+ player?.pause()
47
+ onTriggered?("time")
48
+ cancelInternal(restoreVolume: true)
49
+ return
50
+ }
51
+
52
+ startCountdownTimer()
53
+ }
54
+
55
+ func sleepAfterMediaItemAtIndex(index: Int) {
56
+ cancelInternal(restoreVolume: true)
57
+ sleepTimerType = "mediaItem"
58
+ sleepTimerTargetIndex = index
59
+ sleepTimerPreviousIndex = player?.currentIndex
60
+ }
61
+
62
+ func cancel() {
63
+ cancelInternal(restoreVolume: true)
64
+ }
65
+
66
+ func getState() -> [String: Any]? {
67
+ guard let type = sleepTimerType else { return nil }
68
+ if type == "time" {
69
+ return [
70
+ "type": "time",
71
+ "remainingSeconds": sleepTimerRemainingSeconds,
72
+ "fadeOutSeconds": sleepTimerFadeOutSeconds
73
+ ]
74
+ } else {
75
+ return [
76
+ "type": "mediaItem",
77
+ "index": sleepTimerTargetIndex ?? 0
78
+ ]
79
+ }
80
+ }
81
+
82
+ /// Call this when the current media item changes.
83
+ /// Returns true if the timer fired.
84
+ func handleItemTransition(to index: Int) -> Bool {
85
+ if sleepTimerType == "mediaItem", let targetIndex = sleepTimerTargetIndex {
86
+ if sleepTimerPreviousIndex == targetIndex && index != targetIndex {
87
+ onTriggered?("mediaItem")
88
+ cancelInternal(restoreVolume: false)
89
+ sleepTimerPreviousIndex = index
90
+ return true
91
+ }
92
+ }
93
+ sleepTimerPreviousIndex = index
94
+ return false
95
+ }
96
+
97
+ /// Call this manually in tests to simulate a tick. In production, the Timer calls this.
98
+ func tick() {
99
+ onSleepTimerTick()
100
+ }
101
+
102
+ // MARK: - Internal
103
+
104
+ func cancelInternal(restoreVolume: Bool) {
105
+ sleepTimer?.invalidate()
106
+ sleepTimer = nil
107
+ if restoreVolume, let preFadeVolume = sleepTimerPreFadeVolume {
108
+ player?.volume = preFadeVolume
109
+ }
110
+ sleepTimerPreFadeVolume = nil
111
+ sleepTimerType = nil
112
+ sleepTimerRemainingSeconds = 0
113
+ sleepTimerFadeOutSeconds = 0
114
+ sleepTimerTargetIndex = nil
115
+ sleepTimerPreviousIndex = nil
116
+ }
117
+
118
+ private func onSleepTimerTick() {
119
+ guard sleepTimerType == "time" else { return }
120
+ sleepTimerRemainingSeconds -= 1
121
+
122
+ if sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds {
123
+ if sleepTimerPreFadeVolume == nil {
124
+ sleepTimerPreFadeVolume = player?.volume ?? 1.0
125
+ }
126
+ let progress = max(0, sleepTimerRemainingSeconds) / sleepTimerFadeOutSeconds
127
+ player?.volume = (sleepTimerPreFadeVolume ?? 1.0) * Float(progress)
128
+ }
129
+
130
+ if sleepTimerRemainingSeconds <= 0 {
131
+ sleepTimerRemainingSeconds = 0
132
+ player?.pause()
133
+ onTriggered?("time")
134
+ cancelInternal(restoreVolume: true)
135
+ return
136
+ }
137
+ }
138
+
139
+ private func startCountdownTimer() {
140
+ sleepTimer?.invalidate()
141
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
142
+ self?.onSleepTimerTick()
143
+ }
144
+ RunLoop.main.add(timer, forMode: .common)
145
+ sleepTimer = timer
146
+ }
147
+ }
@@ -39,6 +39,82 @@ final class AVPlayerEngineIntegrationTests: XCTestCase {
39
39
  return url
40
40
  }
41
41
 
42
+ /// Creates ADTS AAC data — a streamable format that AVPlayer can play
43
+ /// without a Content-Length header.
44
+ private func createADTSAACData(duration: Double) -> Data? {
45
+ let sampleRate: Double = 44100
46
+ let frameCount = AVAudioFrameCount(sampleRate * duration)
47
+
48
+ let pcmFormat = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
49
+ let silentBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: frameCount)!
50
+ silentBuffer.frameLength = frameCount
51
+
52
+ guard let aacFormat = AVAudioFormat(
53
+ commonFormat: .pcmFormatFloat32,
54
+ sampleRate: sampleRate,
55
+ channels: 1,
56
+ interleaved: false
57
+ ) else { return nil }
58
+
59
+ var classDesc = AudioClassDescription(
60
+ mType: kAudioEncoderComponentType,
61
+ mSubType: kAudioFormatMPEG4AAC,
62
+ mManufacturer: kAppleSoftwareAudioCodecManufacturer
63
+ )
64
+ var outputASBD = AudioStreamBasicDescription(
65
+ mSampleRate: sampleRate,
66
+ mFormatID: kAudioFormatMPEG4AAC,
67
+ mFormatFlags: 0,
68
+ mBytesPerPacket: 0,
69
+ mFramesPerPacket: 1024,
70
+ mBytesPerFrame: 0,
71
+ mChannelsPerFrame: 1,
72
+ mBitsPerChannel: 0,
73
+ mReserved: 0
74
+ )
75
+ guard let aacOutputFormat = AVAudioFormat(streamDescription: &outputASBD) else { return nil }
76
+ guard let converter = AVAudioConverter(from: pcmFormat, to: aacOutputFormat) else { return nil }
77
+
78
+ var aacData = Data()
79
+ let outputBuffer = AVAudioCompressedBuffer(format: aacOutputFormat, packetCapacity: 1, maximumPacketSize: 768)
80
+
81
+ var inputConsumed = false
82
+ let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
83
+ if inputConsumed {
84
+ outStatus.pointee = .endOfStream
85
+ return nil
86
+ }
87
+ inputConsumed = true
88
+ outStatus.pointee = .haveData
89
+ return silentBuffer
90
+ }
91
+
92
+ while true {
93
+ outputBuffer.packetCount = 0
94
+ outputBuffer.byteLength = 0
95
+ var error: NSError?
96
+ let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
97
+ if outputBuffer.byteLength > 0 {
98
+ // Wrap each AAC packet in an ADTS header
99
+ let packetSize = Int(outputBuffer.byteLength)
100
+ let frameLength = packetSize + 7
101
+ var adtsHeader = Data(count: 7)
102
+ adtsHeader[0] = 0xFF
103
+ adtsHeader[1] = 0xF1 // MPEG-4, Layer 0, no CRC
104
+ adtsHeader[2] = 0x50 // AAC-LC, 44100Hz, mono (upper bits)
105
+ adtsHeader[3] = UInt8(0x80) | UInt8((frameLength >> 11) & 0x03)
106
+ adtsHeader[4] = UInt8((frameLength >> 3) & 0xFF)
107
+ adtsHeader[5] = UInt8(((frameLength & 0x07) << 5) | 0x1F)
108
+ adtsHeader[6] = 0xFC
109
+ aacData.append(adtsHeader)
110
+ aacData.append(Data(bytes: outputBuffer.data, count: packetSize))
111
+ }
112
+ if status == .endOfStream || status == .error { break }
113
+ }
114
+
115
+ return aacData.isEmpty ? nil : aacData
116
+ }
117
+
42
118
  private func loadAndWaitForReady() {
43
119
  let ready = expectation(description: "item ready")
44
120
  ready.assertForOverFulfill = false
@@ -201,4 +277,158 @@ final class AVPlayerEngineIntegrationTests: XCTestCase {
201
277
 
202
278
  XCTAssertTrue(receivedStates.contains(.playing), "should have received .playing")
203
279
  }
280
+
281
+ // MARK: - Caching Without Content-Length
282
+
283
+ func testCachedPlaybackCompletesWithContentLength() throws {
284
+ let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
285
+ let cachedEngine = AVPlayerEngine(cache: cache)
286
+ defer { cachedEngine.reset() }
287
+
288
+ // Use ADTS AAC — a streamable format that the HTTP proxy can serve
289
+ // progressively. WAV requires byte-range access which is not available
290
+ // until the full file is cached.
291
+ guard let audioData = createADTSAACData(duration: 1.0) else {
292
+ throw XCTSkip("Could not create ADTS AAC test data")
293
+ }
294
+ let server = try LocalAudioServer(
295
+ audioData: audioData,
296
+ options: .init(includeContentLength: true, contentType: "audio/aac")
297
+ )
298
+ server.start()
299
+ defer { server.stop() }
300
+
301
+ let ready = expectation(description: "ready")
302
+ ready.assertForOverFulfill = false
303
+ cachedEngine.onItemReady = { ready.fulfill() }
304
+
305
+ let failed = expectation(description: "should not fail")
306
+ failed.isInverted = true
307
+ cachedEngine.onItemFailed = { _, _ in failed.fulfill() }
308
+
309
+ cachedEngine.load(url: server.url)
310
+ wait(for: [ready], timeout: 10.0)
311
+
312
+ let ended = expectation(description: "ended")
313
+ cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
314
+ cachedEngine.play()
315
+ wait(for: [ended, failed], timeout: 10.0)
316
+ }
317
+
318
+ func testCachedPlaybackCompletesWithoutContentLength() throws {
319
+ // Use ADTS AAC — a streamable format that AVPlayer can handle without
320
+ // Content-Length (unlike WAV/M4A which require it).
321
+ guard let audioData = createADTSAACData(duration: 1.0) else {
322
+ throw XCTSkip("Could not create ADTS AAC test data")
323
+ }
324
+
325
+ let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
326
+ let cachedEngine = AVPlayerEngine(cache: cache)
327
+ defer { cachedEngine.reset() }
328
+ let server = try LocalAudioServer(
329
+ audioData: audioData,
330
+ options: .init(includeContentLength: false, contentType: "audio/aac")
331
+ )
332
+ server.start()
333
+ defer { server.stop() }
334
+
335
+ let ready = expectation(description: "ready")
336
+ ready.assertForOverFulfill = false
337
+ cachedEngine.onItemReady = { ready.fulfill() }
338
+
339
+ let failed = expectation(description: "should not fail")
340
+ failed.isInverted = true
341
+ cachedEngine.onItemFailed = { _, _ in failed.fulfill() }
342
+
343
+ cachedEngine.load(url: server.url)
344
+ wait(for: [ready], timeout: 10.0)
345
+
346
+ let ended = expectation(description: "ended")
347
+ cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
348
+ cachedEngine.play()
349
+ wait(for: [ended, failed], timeout: 10.0)
350
+ }
351
+
352
+ // MARK: - Cache Hit Playback
353
+
354
+ func testPlaybackFromCacheHit() throws {
355
+ let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
356
+ let cachedEngine = AVPlayerEngine(cache: cache)
357
+ defer { cachedEngine.reset() }
358
+
359
+ // Use ADTS AAC — streamable format
360
+ guard let audioData = createADTSAACData(duration: 1.0) else {
361
+ throw XCTSkip("Could not create ADTS AAC test data")
362
+ }
363
+ let server = try LocalAudioServer(
364
+ audioData: audioData,
365
+ options: .init(includeContentLength: true, contentType: "audio/aac")
366
+ )
367
+ server.start()
368
+
369
+ // First play — downloads and caches through the proxy.
370
+ let ready1 = expectation(description: "first play ready")
371
+ ready1.assertForOverFulfill = false
372
+ cachedEngine.onItemReady = { ready1.fulfill() }
373
+ cachedEngine.load(url: server.url)
374
+ wait(for: [ready1], timeout: 10.0)
375
+
376
+ let ended1 = expectation(description: "first play ended")
377
+ cachedEngine.onItemPlayedToEnd = { ended1.fulfill() }
378
+ cachedEngine.play()
379
+ wait(for: [ended1], timeout: 10.0)
380
+
381
+ // Stop the server — second play must come from cache.
382
+ server.stop()
383
+
384
+ let ready2 = expectation(description: "cache hit ready")
385
+ ready2.assertForOverFulfill = false
386
+ cachedEngine.onItemReady = { ready2.fulfill() }
387
+ cachedEngine.load(url: server.url)
388
+ wait(for: [ready2], timeout: 10.0)
389
+
390
+ let ended2 = expectation(description: "cache hit ended")
391
+ cachedEngine.onItemPlayedToEnd = { ended2.fulfill() }
392
+ cachedEngine.play()
393
+ wait(for: [ended2], timeout: 10.0)
394
+ }
395
+
396
+ // MARK: - Skip Mid-Download Preserves Partial Cache
397
+
398
+ func testSkipMidDownloadPreservesPartialCache() throws {
399
+ let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
400
+ let cachedEngine = AVPlayerEngine(cache: cache)
401
+ defer { cachedEngine.reset() }
402
+
403
+ guard let aacData = createADTSAACData(duration: 5.0) else {
404
+ throw XCTSkip("Could not create AAC test data")
405
+ }
406
+ let server = try LocalAudioServer(
407
+ audioData: aacData,
408
+ options: .init(includeContentLength: true, contentType: "audio/aac")
409
+ )
410
+ server.start()
411
+ defer { server.stop() }
412
+
413
+ let ready = expectation(description: "ready")
414
+ ready.assertForOverFulfill = false
415
+ cachedEngine.onItemReady = { ready.fulfill() }
416
+ cachedEngine.load(url: server.url)
417
+ wait(for: [ready], timeout: 10.0)
418
+
419
+ // Reset (simulates skipping to next track).
420
+ cachedEngine.reset()
421
+
422
+ // Check that some data was cached (partial).
423
+ let key = cache.cacheKey(for: server.url)
424
+ let cachedBytes = cache.cachedBytes(for: key)
425
+ XCTAssertGreaterThan(cachedBytes, 0, "Some data should be cached after partial play")
426
+ }
427
+
428
+ // MARK: - Helpers
429
+
430
+ private lazy var cacheDir: URL = {
431
+ FileManager.default.temporaryDirectory
432
+ .appendingPathComponent("CacheTests-\(UUID().uuidString)", isDirectory: true)
433
+ }()
204
434
  }
@@ -20,6 +20,12 @@ private struct MockItem: AudioItem {
20
20
  self.sourceUrl = "https://example.com/\(id).mp3"
21
21
  self.title = id
22
22
  }
23
+
24
+ #if canImport(UIKit)
25
+ func getArtwork(_ handler: @escaping (UIImage?) -> Void) {
26
+ handler(nil)
27
+ }
28
+ #endif
23
29
  }
24
30
 
25
31
  private func item(_ id: String) -> MockItem { MockItem(id) }