@rntp/player 5.0.0-beta.3 → 5.0.0-beta.5
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 +107 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
- 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/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
- package/ios/TrackPlayer.swift +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- 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 +39 -4
- 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 +37 -4
- 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 +16 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
- package/lib/typescript/src/interfaces/BrowseTree.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 +19 -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 +37 -4
- package/src/interfaces/BrowseTree.ts +40 -5
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
package/ios/TrackPlayer.swift
CHANGED
|
@@ -22,13 +22,7 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
22
22
|
private var progressSyncHttpUrl: String?
|
|
23
23
|
private var progressSyncHttpHeaders: [String: String]?
|
|
24
24
|
|
|
25
|
-
private var
|
|
26
|
-
private var sleepTimerRemainingSeconds: Double = 0
|
|
27
|
-
private var sleepTimerFadeOutSeconds: Double = 0
|
|
28
|
-
private var sleepTimerTargetIndex: Int?
|
|
29
|
-
private var sleepTimer: Timer?
|
|
30
|
-
private var sleepTimerPreFadeVolume: Float?
|
|
31
|
-
private var sleepTimerPreviousIndex: Int?
|
|
25
|
+
private var sleepTimerController: SleepTimerController?
|
|
32
26
|
|
|
33
27
|
// MARK: - Initializers
|
|
34
28
|
|
|
@@ -71,19 +65,29 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
71
65
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: mode)
|
|
72
66
|
|
|
73
67
|
var cache: AudioCache? = nil
|
|
68
|
+
var preloadWindow = 0
|
|
74
69
|
if let cacheConfig = config["cache"] as? [String: Any] {
|
|
75
|
-
let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (
|
|
70
|
+
let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (500 * 1024 * 1024)
|
|
76
71
|
cache = AudioCache(maxSizeBytes: maxSize)
|
|
72
|
+
if let preloadConfig = cacheConfig["preloading"] as? [String: Any] {
|
|
73
|
+
preloadWindow = (preloadConfig["window"] as? NSNumber)?.intValue ?? 0
|
|
74
|
+
}
|
|
77
75
|
}
|
|
78
76
|
audioCache = cache
|
|
79
77
|
|
|
80
78
|
let handleNoisy = config["handleAudioBecomingNoisy"] as? Bool ?? true
|
|
81
79
|
player = AudioPlayer(handleAudioBecomingNoisy: handleNoisy, cache: cache)
|
|
80
|
+
player.preloadWindow = preloadWindow
|
|
82
81
|
bindPlayerCallbacks()
|
|
83
82
|
BrowseTreeStore.shared.player = player
|
|
84
83
|
lastEmittedStateString = nil
|
|
85
84
|
lastIsPlaying = false
|
|
86
85
|
|
|
86
|
+
sleepTimerController = SleepTimerController(player: player)
|
|
87
|
+
sleepTimerController?.onTriggered = { [weak self] type in
|
|
88
|
+
self?.emitEvent(event: SleepTimerTriggeredEvent(sleepType: type))
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
if let progressSync = config["progressSync"] as? [String: Any] {
|
|
88
92
|
progressSyncIntervalSeconds = progressSync["intervalSeconds"] as? Double ?? 0
|
|
89
93
|
if let http = progressSync["http"] as? [String: Any] {
|
|
@@ -152,6 +156,28 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
152
156
|
audioCache?.removeAll()
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
@objc(preload:duration:)
|
|
160
|
+
func preload(item: [String: Any], duration: Double) {
|
|
161
|
+
guard let urlString = extractUrl(from: item),
|
|
162
|
+
let url = URL(string: urlString) else { return }
|
|
163
|
+
|
|
164
|
+
let headers = item["headers"] as? [String: String]
|
|
165
|
+
player.preloader?.preload(url: url, headers: headers)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@objc(cancelPreload:)
|
|
169
|
+
func cancelPreload(item: [String: Any]) {
|
|
170
|
+
guard let urlString = extractUrl(from: item),
|
|
171
|
+
let url = URL(string: urlString) else { return }
|
|
172
|
+
player.preloader?.cancel(url: url)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private func extractUrl(from item: [String: Any]) -> String? {
|
|
176
|
+
if let url = item["url"] as? String { return url }
|
|
177
|
+
if let urlObj = item["url"] as? [String: Any], let uri = urlObj["uri"] as? String { return uri }
|
|
178
|
+
return nil
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
@objc(setPlaybackSpeed:)
|
|
156
182
|
func setPlaybackSpeed(speed: Double) {
|
|
157
183
|
player.rate = Float(speed)
|
|
@@ -233,8 +259,8 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
233
259
|
@objc(clear)
|
|
234
260
|
func clear() {
|
|
235
261
|
player.clear()
|
|
236
|
-
if sleepTimerType == "mediaItem" {
|
|
237
|
-
|
|
262
|
+
if sleepTimerController?.sleepTimerType == "mediaItem" {
|
|
263
|
+
sleepTimerController?.cancelInternal(restoreVolume: false)
|
|
238
264
|
}
|
|
239
265
|
emitEvent(event: QueueChangedEvent())
|
|
240
266
|
}
|
|
@@ -413,101 +439,24 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
413
439
|
|
|
414
440
|
// MARK: - Sleep Timer
|
|
415
441
|
|
|
416
|
-
private func cancelSleepTimerInternal(restoreVolume: Bool) {
|
|
417
|
-
sleepTimer?.invalidate()
|
|
418
|
-
sleepTimer = nil
|
|
419
|
-
if restoreVolume, let preFadeVolume = sleepTimerPreFadeVolume {
|
|
420
|
-
player.volume = preFadeVolume
|
|
421
|
-
}
|
|
422
|
-
sleepTimerPreFadeVolume = nil
|
|
423
|
-
sleepTimerType = nil
|
|
424
|
-
sleepTimerRemainingSeconds = 0
|
|
425
|
-
sleepTimerFadeOutSeconds = 0
|
|
426
|
-
sleepTimerTargetIndex = nil
|
|
427
|
-
sleepTimerPreviousIndex = nil
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
private func onSleepTimerTick() {
|
|
431
|
-
sleepTimerRemainingSeconds -= 1
|
|
432
|
-
|
|
433
|
-
// Handle fade-out (before zero-check so final tick sets volume to 0)
|
|
434
|
-
if sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds {
|
|
435
|
-
if sleepTimerPreFadeVolume == nil {
|
|
436
|
-
sleepTimerPreFadeVolume = player.volume
|
|
437
|
-
}
|
|
438
|
-
let progress = max(0, sleepTimerRemainingSeconds) / sleepTimerFadeOutSeconds
|
|
439
|
-
player.volume = (sleepTimerPreFadeVolume ?? 1.0) * Float(progress)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if sleepTimerRemainingSeconds <= 0 {
|
|
443
|
-
sleepTimerRemainingSeconds = 0
|
|
444
|
-
player.pause()
|
|
445
|
-
emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
|
|
446
|
-
// Restore volume after pausing so next playback isn't muted
|
|
447
|
-
cancelSleepTimerInternal(restoreVolume: true)
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private func startSleepCountdownTimer() {
|
|
453
|
-
sleepTimer?.invalidate()
|
|
454
|
-
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
455
|
-
self?.onSleepTimerTick()
|
|
456
|
-
}
|
|
457
|
-
RunLoop.main.add(timer, forMode: .common)
|
|
458
|
-
sleepTimer = timer
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
private func pauseSleepCountdownTimer() {
|
|
462
|
-
sleepTimer?.invalidate()
|
|
463
|
-
sleepTimer = nil
|
|
464
|
-
}
|
|
465
|
-
|
|
466
442
|
@objc(sleepAfterTime:fadeOutSeconds:)
|
|
467
443
|
func sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
|
|
468
|
-
|
|
469
|
-
sleepTimerType = "time"
|
|
470
|
-
sleepTimerRemainingSeconds = seconds
|
|
471
|
-
sleepTimerFadeOutSeconds = min(fadeOutSeconds, seconds)
|
|
472
|
-
|
|
473
|
-
if seconds <= 0 {
|
|
474
|
-
player.pause()
|
|
475
|
-
emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
|
|
476
|
-
cancelSleepTimerInternal(restoreVolume: true)
|
|
477
|
-
return
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
startSleepCountdownTimer()
|
|
444
|
+
sleepTimerController?.sleepAfterTime(seconds: seconds, fadeOutSeconds: fadeOutSeconds)
|
|
481
445
|
}
|
|
482
446
|
|
|
483
447
|
@objc(sleepAfterMediaItemAtIndex:)
|
|
484
448
|
func sleepAfterMediaItemAtIndex(index: Double) {
|
|
485
|
-
|
|
486
|
-
sleepTimerType = "mediaItem"
|
|
487
|
-
sleepTimerTargetIndex = Int(index)
|
|
488
|
-
sleepTimerPreviousIndex = player.currentIndex
|
|
449
|
+
sleepTimerController?.sleepAfterMediaItemAtIndex(index: Int(index))
|
|
489
450
|
}
|
|
490
451
|
|
|
491
452
|
@objc(getSleepTimer)
|
|
492
453
|
func getSleepTimer() -> [String: Any]? {
|
|
493
|
-
|
|
494
|
-
if type == "time" {
|
|
495
|
-
return [
|
|
496
|
-
"type": "time",
|
|
497
|
-
"remainingSeconds": sleepTimerRemainingSeconds,
|
|
498
|
-
"fadeOutSeconds": sleepTimerFadeOutSeconds
|
|
499
|
-
]
|
|
500
|
-
} else {
|
|
501
|
-
return [
|
|
502
|
-
"type": "mediaItem",
|
|
503
|
-
"index": sleepTimerTargetIndex ?? 0
|
|
504
|
-
]
|
|
505
|
-
}
|
|
454
|
+
return sleepTimerController?.getState()
|
|
506
455
|
}
|
|
507
456
|
|
|
508
457
|
@objc(cancelSleepTimer)
|
|
509
458
|
func cancelSleepTimer() {
|
|
510
|
-
|
|
459
|
+
sleepTimerController?.cancel()
|
|
511
460
|
}
|
|
512
461
|
|
|
513
462
|
// MARK: - Browse Tree
|
|
@@ -522,7 +471,8 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
522
471
|
@objc(destroy)
|
|
523
472
|
func destroy() {
|
|
524
473
|
BrowseTreeStore.shared.clear()
|
|
525
|
-
|
|
474
|
+
sleepTimerController?.cancel()
|
|
475
|
+
sleepTimerController = nil
|
|
526
476
|
stopProgressSyncTimer(fireFinalTick: false)
|
|
527
477
|
let commandCenter = MPRemoteCommandCenter.shared()
|
|
528
478
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
|
@@ -580,17 +530,12 @@ class TrackPlayer: RCTEventEmitter {
|
|
|
580
530
|
|
|
581
531
|
func handleCurrentItemChanged(item: AudioItem?, index: Int) {
|
|
582
532
|
// Sleep timer: check if the target item just finished
|
|
583
|
-
if
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// Defer pause to next run loop — calling during transition gets overridden
|
|
588
|
-
DispatchQueue.main.async { [weak self] in
|
|
589
|
-
self?.player.pause()
|
|
590
|
-
}
|
|
533
|
+
if sleepTimerController?.handleItemTransition(to: index) == true {
|
|
534
|
+
// Defer pause to next run loop — calling during transition gets overridden
|
|
535
|
+
DispatchQueue.main.async { [weak self] in
|
|
536
|
+
self?.player.pause()
|
|
591
537
|
}
|
|
592
538
|
}
|
|
593
|
-
sleepTimerPreviousIndex = index
|
|
594
539
|
let mediaItem = item as? MediaItem
|
|
595
540
|
let dict = mediaItem?.toDictionary()
|
|
596
541
|
BrowseTreeStore.shared.updateNowPlaying(mediaId: mediaItem?.mediaId)
|
package/ios/TrackPlayerBridge.mm
CHANGED
|
@@ -29,6 +29,8 @@ RCT_EXTERN_METHOD(skipToPrevious);
|
|
|
29
29
|
RCT_EXTERN_METHOD(skipToIndex:(double)index);
|
|
30
30
|
RCT_EXTERN_METHOD(retry);
|
|
31
31
|
RCT_EXTERN_METHOD(clearCache);
|
|
32
|
+
RCT_EXTERN_METHOD(preload:(NSDictionary *)item duration:(double)duration);
|
|
33
|
+
RCT_EXTERN_METHOD(cancelPreload:(NSDictionary *)item);
|
|
32
34
|
RCT_EXTERN_METHOD(setPlaybackSpeed:(double)speed);
|
|
33
35
|
RCT_EXTERN_METHOD(setVolume:(double)volume);
|
|
34
36
|
|
|
@@ -23,8 +23,14 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
23
23
|
|
|
24
24
|
// Caching
|
|
25
25
|
private let cache: AudioCache?
|
|
26
|
-
private
|
|
27
|
-
private let
|
|
26
|
+
private let proxyServer: CacheProxyServer?
|
|
27
|
+
private let _downloadCoordinator: DownloadCoordinator?
|
|
28
|
+
|
|
29
|
+
/// Exposed so AudioPlayer can pass it to the Preloader.
|
|
30
|
+
var downloadCoordinator: DownloadCoordinator? { _downloadCoordinator }
|
|
31
|
+
|
|
32
|
+
/// Cache key for the currently loaded URL, used for cachedPosition.
|
|
33
|
+
private(set) var currentCacheKey: String?
|
|
28
34
|
|
|
29
35
|
// MARK: - Callbacks
|
|
30
36
|
|
|
@@ -59,14 +65,20 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
var cachedPosition: Double {
|
|
62
|
-
guard let cache = cache, let
|
|
63
|
-
let bytes = cache.cachedBytes(for:
|
|
64
|
-
guard bytes > 0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
guard let cache = cache, let key = currentCacheKey else { return 0 }
|
|
69
|
+
let bytes = cache.cachedBytes(for: key)
|
|
70
|
+
guard bytes > 0 else { return 0 }
|
|
71
|
+
|
|
72
|
+
if let info = cache.contentInfo(for: key), info.contentLength > 0 {
|
|
73
|
+
let dur = duration
|
|
74
|
+
guard dur > 0 else { return 0 }
|
|
75
|
+
return (Double(bytes) / Double(info.contentLength)) * dur
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Content length unknown (download in progress, no response yet).
|
|
79
|
+
// Fall back to bufferedPosition — since AVPlayer streams through
|
|
80
|
+
// the proxy, what's buffered approximates what's cached.
|
|
81
|
+
return bufferedPosition
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
var defaultRate: Float {
|
|
@@ -99,6 +111,17 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
99
111
|
init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
|
|
100
112
|
self.handleAudioBecomingNoisy = handleAudioBecomingNoisy
|
|
101
113
|
self.cache = cache
|
|
114
|
+
|
|
115
|
+
if let cache = cache {
|
|
116
|
+
let coordinator = DownloadCoordinator(cache: cache)
|
|
117
|
+
self._downloadCoordinator = coordinator
|
|
118
|
+
self.proxyServer = try? CacheProxyServer(coordinator: coordinator, cache: cache)
|
|
119
|
+
self.proxyServer?.start()
|
|
120
|
+
} else {
|
|
121
|
+
self._downloadCoordinator = nil
|
|
122
|
+
self.proxyServer = nil
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
avPlayer.allowsExternalPlayback = true
|
|
103
126
|
avPlayer.usesExternalPlaybackWhileExternalScreenIsActive = true
|
|
104
127
|
observeTimeControlStatus()
|
|
@@ -111,7 +134,7 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
deinit {
|
|
114
|
-
|
|
137
|
+
proxyServer?.stop()
|
|
115
138
|
timeControlObservation?.invalidate()
|
|
116
139
|
removeItemObservers()
|
|
117
140
|
if let observer = interruptionObserver {
|
|
@@ -133,20 +156,18 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
func load(url: URL, headers: [String: String]? = nil, isLive: Bool = false) {
|
|
136
|
-
currentResourceLoader?.invalidate()
|
|
137
|
-
currentResourceLoader = nil
|
|
138
|
-
|
|
139
159
|
let asset: AVURLAsset
|
|
140
160
|
|
|
141
|
-
if !isLive, let
|
|
142
|
-
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
asset = AVURLAsset(url:
|
|
146
|
-
asset.resourceLoader.setDelegate(loader, queue: resourceLoaderQueue)
|
|
161
|
+
if !isLive, let proxy = proxyServer, let cache = cache,
|
|
162
|
+
url.scheme?.hasPrefix("http") == true {
|
|
163
|
+
let proxyURL = proxy.proxyURL(for: url, headers: headers)
|
|
164
|
+
currentCacheKey = cache.cacheKey(for: url)
|
|
165
|
+
asset = AVURLAsset(url: proxyURL)
|
|
147
166
|
} else if let headers = headers, !headers.isEmpty {
|
|
167
|
+
currentCacheKey = nil
|
|
148
168
|
asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
|
149
169
|
} else {
|
|
170
|
+
currentCacheKey = nil
|
|
150
171
|
asset = AVURLAsset(url: url)
|
|
151
172
|
}
|
|
152
173
|
|
|
@@ -162,8 +183,7 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
func reset() {
|
|
165
|
-
|
|
166
|
-
currentResourceLoader = nil
|
|
186
|
+
currentCacheKey = nil
|
|
167
187
|
removeItemObservers()
|
|
168
188
|
avPlayer.replaceCurrentItem(with: nil)
|
|
169
189
|
}
|
|
@@ -260,14 +280,15 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
260
280
|
private func observeItem(_ item: AVPlayerItem) {
|
|
261
281
|
itemStatusObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
|
|
262
282
|
DispatchQueue.main.async {
|
|
283
|
+
guard let self = self else { return }
|
|
263
284
|
switch item.status {
|
|
264
285
|
case .readyToPlay:
|
|
265
|
-
self
|
|
286
|
+
self.emitAssetMetadata(from: item)
|
|
266
287
|
case .failed:
|
|
267
288
|
let nsError = item.error as NSError?
|
|
268
289
|
let code = Self.classifyError(nsError)
|
|
269
290
|
let message = nsError?.localizedDescription ?? "Unknown playback error"
|
|
270
|
-
self
|
|
291
|
+
self.onItemFailed?(code, message)
|
|
271
292
|
default:
|
|
272
293
|
break
|
|
273
294
|
}
|
|
@@ -341,15 +362,8 @@ class AVPlayerEngine: PlayerEngine {
|
|
|
341
362
|
// MARK: - Asset Metadata
|
|
342
363
|
|
|
343
364
|
/// Extracts static metadata (ID3, etc.) from the asset when the item becomes ready.
|
|
344
|
-
/// When caching is active the playback asset uses a custom URL scheme, so we use
|
|
345
|
-
/// the original HTTP URL for metadata extraction instead.
|
|
346
365
|
private func emitAssetMetadata(from item: AVPlayerItem) {
|
|
347
|
-
let asset
|
|
348
|
-
if let loader = currentResourceLoader {
|
|
349
|
-
asset = AVURLAsset(url: loader.originalURL)
|
|
350
|
-
} else {
|
|
351
|
-
asset = item.asset as! AVURLAsset
|
|
352
|
-
}
|
|
366
|
+
let asset = item.asset as! AVURLAsset
|
|
353
367
|
asset.loadValuesAsynchronously(forKeys: ["commonMetadata"]) { [weak self] in
|
|
354
368
|
var error: NSError?
|
|
355
369
|
let status = asset.statusOfValue(forKey: "commonMetadata", error: &error)
|
|
@@ -117,6 +117,40 @@ final class AudioCache: @unchecked Sendable {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// MARK: - Debug
|
|
121
|
+
|
|
122
|
+
/// Dumps the full cache state to the console for debugging.
|
|
123
|
+
func dumpState(currentKey: String? = nil) {
|
|
124
|
+
queue.sync {
|
|
125
|
+
let fm = FileManager.default
|
|
126
|
+
guard let contents = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else {
|
|
127
|
+
print("[AudioCache] empty or unreadable")
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let metaFiles = contents.filter { $0.pathExtension == "meta" }
|
|
132
|
+
let totalSize = metaFiles.reduce(Int64(0)) { total, metaURL in
|
|
133
|
+
let key = metaURL.deletingPathExtension().lastPathComponent
|
|
134
|
+
return total + dataFileSize(for: key)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
print("[AudioCache] \(metaFiles.count) items, \(totalSize / 1024 / 1024)MB / \(maxSize / 1024 / 1024)MB max")
|
|
138
|
+
|
|
139
|
+
for metaURL in metaFiles {
|
|
140
|
+
let key = metaURL.deletingPathExtension().lastPathComponent
|
|
141
|
+
let m = meta(for: key)
|
|
142
|
+
let size = dataFileSize(for: key)
|
|
143
|
+
let cl = m?.contentInfo?.contentLength ?? -1
|
|
144
|
+
let ct = m?.contentInfo?.contentType ?? "?"
|
|
145
|
+
let url = m?.url ?? "?"
|
|
146
|
+
let pct = cl > 0 ? Int(Double(size) / Double(cl) * 100) : -1
|
|
147
|
+
let isCurrent = (key == currentKey) ? " ← CURRENT" : ""
|
|
148
|
+
let urlShort = url.count > 60 ? "…" + url.suffix(55) : url
|
|
149
|
+
print(" [\(key.prefix(8))…] \(size / 1024)KB / \(cl > 0 ? "\(cl / 1024)KB" : "??") (\(pct)%) \(ct) \(urlShort)\(isCurrent)")
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
// MARK: - Internal File Helpers
|
|
121
155
|
|
|
122
156
|
private func dataFileURL(for key: String) -> URL {
|
|
@@ -22,11 +22,13 @@ class AudioPlayer {
|
|
|
22
22
|
|
|
23
23
|
private let engine: PlayerEngine
|
|
24
24
|
private let queue = QueueManager()
|
|
25
|
-
private var preloader: Preloader?
|
|
25
|
+
private(set) var preloader: Preloader?
|
|
26
26
|
#if canImport(UIKit)
|
|
27
27
|
private let artworkCache = NSCache<NSString, UIImage>()
|
|
28
28
|
#endif
|
|
29
29
|
|
|
30
|
+
var preloadWindow: Int = 0
|
|
31
|
+
|
|
30
32
|
private static let previousThreshold: Double = 3.0
|
|
31
33
|
private var lastEmittedMetadata: StreamMetadata?
|
|
32
34
|
private var pendingItemMetadata: StreamMetadata?
|
|
@@ -77,20 +79,33 @@ class AudioPlayer {
|
|
|
77
79
|
|
|
78
80
|
// MARK: - Init
|
|
79
81
|
|
|
80
|
-
init(engine: PlayerEngine, cache: AudioCache? = nil) {
|
|
82
|
+
init(engine: PlayerEngine, cache: AudioCache? = nil, coordinator: DownloadCoordinator? = nil) {
|
|
81
83
|
self.engine = engine
|
|
82
|
-
if let cache = cache {
|
|
83
|
-
self.preloader = Preloader(cache: cache)
|
|
84
|
+
if let cache = cache, let coordinator = coordinator {
|
|
85
|
+
self.preloader = Preloader(cache: cache, coordinator: coordinator)
|
|
86
|
+
}
|
|
87
|
+
coordinator?.onDownloadComplete = { [weak self] key, isFull in
|
|
88
|
+
guard let self = self, isFull else { return }
|
|
89
|
+
if let engine = self.engine as? AVPlayerEngine,
|
|
90
|
+
engine.currentCacheKey == key {
|
|
91
|
+
DispatchQueue.main.async {
|
|
92
|
+
self.onCurrentTrackCached()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
84
95
|
}
|
|
85
96
|
setupEngineCallbacks()
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
#if os(iOS)
|
|
88
100
|
convenience init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
|
|
101
|
+
let engine = AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache)
|
|
89
102
|
self.init(
|
|
90
|
-
engine:
|
|
91
|
-
cache: cache
|
|
103
|
+
engine: engine,
|
|
104
|
+
cache: cache,
|
|
105
|
+
coordinator: engine.downloadCoordinator
|
|
92
106
|
)
|
|
93
107
|
}
|
|
108
|
+
#endif
|
|
94
109
|
|
|
95
110
|
private func setupEngineCallbacks() {
|
|
96
111
|
engine.onPlaybackStateChange = { [weak self] status in
|
|
@@ -268,7 +283,7 @@ class AudioPlayer {
|
|
|
268
283
|
// MARK: - Destroy
|
|
269
284
|
|
|
270
285
|
func destroy() {
|
|
271
|
-
preloader?.
|
|
286
|
+
preloader?.cancelAll()
|
|
272
287
|
clear()
|
|
273
288
|
onStateChange = nil
|
|
274
289
|
onCurrentItemChanged = nil
|
|
@@ -313,23 +328,23 @@ class AudioPlayer {
|
|
|
313
328
|
state = .loading
|
|
314
329
|
updateNowPlayingMetadata(for: item)
|
|
315
330
|
onCurrentItemChanged?(item, queue.currentIndex)
|
|
316
|
-
if !item.isLive { preloadNextItem() }
|
|
317
331
|
}
|
|
318
332
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
let
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
333
|
+
/// Called when the current track's download completes (fully cached).
|
|
334
|
+
private func onCurrentTrackCached() {
|
|
335
|
+
guard preloadWindow > 0, let preloader = preloader else { return }
|
|
336
|
+
preloader.cancelAll()
|
|
337
|
+
|
|
338
|
+
let startIndex = queue.currentIndex + 1
|
|
339
|
+
let endIndex = min(startIndex + preloadWindow, queue.items.count)
|
|
340
|
+
guard startIndex < endIndex else { return }
|
|
341
|
+
|
|
342
|
+
for i in startIndex..<endIndex {
|
|
343
|
+
let item = queue.items[i]
|
|
344
|
+
guard !item.isLive, item.sourceType == .stream,
|
|
345
|
+
let url = URL(string: item.sourceUrl) else { continue }
|
|
346
|
+
preloader.preload(url: url, headers: item.headers)
|
|
331
347
|
}
|
|
332
|
-
preloader.preload(url: url, headers: nextItem.headers)
|
|
333
348
|
}
|
|
334
349
|
|
|
335
350
|
// MARK: - State Handling
|