@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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/TrackPlayer.swift +47 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +47 -35
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +70 -22
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +19 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +17 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +12 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +18 -0
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +24 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
|
@@ -5,110 +5,41 @@
|
|
|
5
5
|
|
|
6
6
|
import Foundation
|
|
7
7
|
|
|
8
|
-
final class Preloader
|
|
9
|
-
|
|
8
|
+
final class Preloader {
|
|
9
|
+
private let coordinator: DownloadCoordinator
|
|
10
10
|
private let cache: AudioCache
|
|
11
|
-
private
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
cancel()
|
|
37
|
-
activeKey = key
|
|
22
|
+
if activeTokens[key] != nil { return }
|
|
38
23
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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) }
|