react-native-nitro-player 1.5.0 → 1.6.0-alpha.0
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/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +58 -29
- package/ios/core/TrackPlayerCore.swift +22 -1
- package/ios/core/TrackPlayerListener.swift +84 -5
- package/ios/core/TrackPlayerNotify.swift +13 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +27 -1
- package/lib/hooks/callbackManager.js +100 -1
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/lib/hooks/useTimedMetadata.d.ts +16 -0
- package/lib/hooks/useTimedMetadata.js +21 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
- package/lib/types/PlayerQueue.d.ts +7 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
- package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +134 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
- package/src/hooks/useTimedMetadata.ts +25 -0
- package/src/specs/TrackPlayer.nitro.ts +6 -0
- package/src/types/PlayerQueue.ts +8 -0
|
@@ -25,40 +25,63 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
25
25
|
super.init()
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// MARK: - Ordered dispatch
|
|
29
|
+
//
|
|
30
|
+
// `Promise.async` starts an unordered Task, so the hop onto the player queue used
|
|
31
|
+
// to happen at an arbitrary later point: two calls made in order from JS could
|
|
32
|
+
// reach the player in reverse order (play-then-pause landing as pause-then-play).
|
|
33
|
+
// Enqueueing synchronously here — on the JS thread, at call time — makes the serial
|
|
34
|
+
// queue's FIFO order equal the JS call order.
|
|
35
|
+
|
|
36
|
+
private func enqueue<T>(_ block: @escaping () -> T) -> Promise<T> {
|
|
37
|
+
let promise = Promise<T>()
|
|
38
|
+
core.playerQueue.async { promise.resolve(withResult: block()) }
|
|
39
|
+
return promise
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private func enqueueThrowing<T>(_ block: @escaping () throws -> T) -> Promise<T> {
|
|
43
|
+
let promise = Promise<T>()
|
|
44
|
+
core.playerQueue.async {
|
|
45
|
+
do { promise.resolve(withResult: try block()) }
|
|
46
|
+
catch { promise.reject(withError: error) }
|
|
47
|
+
}
|
|
48
|
+
return promise
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
// MARK: - Playback Control (async Promise<Void>)
|
|
29
52
|
|
|
30
53
|
func play() throws -> Promise<Void> {
|
|
31
|
-
|
|
54
|
+
enqueue { self.core.playOnQueue() }
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
func pause() throws -> Promise<Void> {
|
|
35
|
-
|
|
58
|
+
enqueue { self.core.pauseOnQueue() }
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
func seek(position: Double) throws -> Promise<Void> {
|
|
39
|
-
|
|
62
|
+
enqueue { self.core.seekOnQueue(position: position) }
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
func skipToNext() throws -> Promise<Void> {
|
|
43
|
-
|
|
66
|
+
enqueue { self.core.skipToNextOnQueue() }
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
func skipToPrevious() throws -> Promise<Void> {
|
|
47
|
-
|
|
70
|
+
enqueue { self.core.skipToPreviousOnQueue() }
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
func playSong(songId: String, fromPlaylist: String?) throws -> Promise<Void> {
|
|
51
|
-
|
|
74
|
+
enqueue { self.core.playSongInternal(songId: songId, fromPlaylist: fromPlaylist) }
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
func skipToIndex(index: Double) throws -> Promise<Bool> {
|
|
55
|
-
|
|
78
|
+
enqueue { self.core.skipToIndexOnQueue(index: Int(index)) }
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// MARK: - Repeat / Volume / Config
|
|
59
82
|
|
|
60
83
|
func setRepeatMode(mode: RepeatMode) throws -> Promise<Void> {
|
|
61
|
-
|
|
84
|
+
enqueue { self.core.setRepeatModeOnQueue(mode: mode) }
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
func getRepeatMode() throws -> RepeatMode {
|
|
@@ -66,12 +89,12 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
func setVolume(volume: Double) throws -> Promise<Void> {
|
|
69
|
-
|
|
92
|
+
enqueue { self.core.setVolumeOnQueue(volume: volume) }
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
func configure(config: PlayerConfig) throws -> Promise<Void> {
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
enqueue {
|
|
97
|
+
self.core.configureOnQueue(
|
|
75
98
|
androidAutoEnabled: config.androidAutoEnabled,
|
|
76
99
|
carPlayEnabled: config.carPlayEnabled,
|
|
77
100
|
showInNotification: config.showInNotification,
|
|
@@ -83,80 +106,80 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
83
106
|
// MARK: - Queue / State reads
|
|
84
107
|
|
|
85
108
|
func getActualQueue() throws -> Promise<[TrackItem]> {
|
|
86
|
-
|
|
109
|
+
enqueue { self.core.getActualQueueInternal() }
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
func getState() throws -> Promise<PlayerState> {
|
|
90
|
-
|
|
113
|
+
enqueue { self.core.getStateInternal() }
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
func getCurrentTrackIndex() throws -> Promise<Double> {
|
|
94
|
-
|
|
117
|
+
enqueue { Double(self.core.currentTrackIndex) }
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
// MARK: - URL updates / lazy loading
|
|
98
121
|
|
|
99
122
|
func updateTracks(tracks: [TrackItem]) throws -> Promise<Void> {
|
|
100
|
-
|
|
123
|
+
enqueue { self.core.updateTracksInternal(tracks: tracks) }
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
func getTracksById(trackIds: [String]) throws -> Promise<[TrackItem]> {
|
|
104
|
-
|
|
127
|
+
enqueue { self.core.getPlaylistManager().getTracksById(trackIds: trackIds) }
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
func getTracksNeedingUrls() throws -> Promise<[TrackItem]> {
|
|
108
|
-
|
|
131
|
+
enqueue { self.core.getTracksNeedingUrlsInternal() }
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
func getNextTracks(count: Double) throws -> Promise<[TrackItem]> {
|
|
112
|
-
|
|
135
|
+
enqueue { self.core.getNextTracksInternal(count: Int(count)) }
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
// MARK: - Playback speed
|
|
116
139
|
func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
|
|
117
|
-
|
|
140
|
+
enqueue { self.core.setPlaybackSpeedOnQueue(speed) }
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
func getPlaybackSpeed() throws -> Promise<Double> {
|
|
121
|
-
|
|
144
|
+
enqueue { self.core.currentPlaybackSpeed }
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
// MARK: - Temporary queue v2
|
|
125
148
|
|
|
126
149
|
func addToUpNext(trackId: String) throws -> Promise<Void> {
|
|
127
|
-
|
|
150
|
+
enqueueThrowing { try self.core.addToUpNextOnQueue(trackId: trackId) }
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
func playNext(trackId: String) throws -> Promise<Void> {
|
|
131
|
-
|
|
154
|
+
enqueueThrowing { try self.core.playNextOnQueue(trackId: trackId) }
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
func removeFromPlayNext(trackId: String) throws -> Promise<Bool> {
|
|
135
|
-
|
|
158
|
+
enqueue { self.core.removeFromPlayNextOnQueue(trackId: trackId) }
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
func removeFromUpNext(trackId: String) throws -> Promise<Bool> {
|
|
139
|
-
|
|
162
|
+
enqueue { self.core.removeFromUpNextOnQueue(trackId: trackId) }
|
|
140
163
|
}
|
|
141
164
|
|
|
142
165
|
func clearPlayNext() throws -> Promise<Void> {
|
|
143
|
-
|
|
166
|
+
enqueue { self.core.clearPlayNextOnQueue() }
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
func clearUpNext() throws -> Promise<Void> {
|
|
147
|
-
|
|
170
|
+
enqueue { self.core.clearUpNextOnQueue() }
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
func reorderTemporaryTrack(trackId: String, newIndex: Double) throws -> Promise<Bool> {
|
|
151
|
-
|
|
174
|
+
enqueue { self.core.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: Int(newIndex)) }
|
|
152
175
|
}
|
|
153
176
|
|
|
154
177
|
func getPlayNextQueue() throws -> Promise<[TrackItem]> {
|
|
155
|
-
|
|
178
|
+
enqueue { self.core.playNextStack }
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
func getUpNextQueue() throws -> Promise<[TrackItem]> {
|
|
159
|
-
|
|
182
|
+
enqueue { self.core.upNextQueue }
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
// MARK: - Android Auto (iOS no-op)
|
|
@@ -196,6 +219,11 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
196
219
|
listenerIds.append(("onTracksNeedUpdate", id))
|
|
197
220
|
}
|
|
198
221
|
|
|
222
|
+
func onTimedMetadata(callback: @escaping (_ metadata: TimedMetadata) -> Void) throws {
|
|
223
|
+
let id = core.addOnTimedMetadataListener(callback)
|
|
224
|
+
listenerIds.append(("onTimedMetadata", id))
|
|
225
|
+
}
|
|
226
|
+
|
|
199
227
|
func onTemporaryQueueChange(callback: @escaping (_ playNextQueue: [TrackItem], _ upNextQueue: [TrackItem]) -> Void) throws {
|
|
200
228
|
let id = core.addOnTemporaryQueueChangeListener(callback)
|
|
201
229
|
listenerIds.append(("onTemporaryQueueChange", id))
|
|
@@ -212,6 +240,7 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
212
240
|
case "onPlaybackProgressChange":_ = core.removeOnProgressListener(id: id)
|
|
213
241
|
case "onTracksNeedUpdate": _ = core.removeOnTracksNeedUpdateListener(id: id)
|
|
214
242
|
case "onTemporaryQueueChange": _ = core.removeOnTemporaryQueueChangeListener(id: id)
|
|
243
|
+
case "onTimedMetadata": _ = core.removeOnTimedMetadataListener(id: id)
|
|
215
244
|
default: break
|
|
216
245
|
}
|
|
217
246
|
}
|
|
@@ -26,6 +26,10 @@ class TrackPlayerCore: NSObject {
|
|
|
26
26
|
static let preferredForwardBufferDuration: Double = 30.0
|
|
27
27
|
static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
|
|
28
28
|
static let gaplessPreloadCount: Int = 3
|
|
29
|
+
/// Upcoming AVPlayerItems kept materialized behind the current one. The logical
|
|
30
|
+
/// queue (currentTracks + temp lists) stays complete; only the AVQueuePlayer is
|
|
31
|
+
/// windowed, and it is topped up on every item transition.
|
|
32
|
+
static let queueWindowSize: Int = 4
|
|
29
33
|
// Stall & failure recovery
|
|
30
34
|
static let maxFailedItemRetries: Int = 3
|
|
31
35
|
static let failedItemRetryDelay: TimeInterval = 2.0
|
|
@@ -44,7 +48,10 @@ class TrackPlayerCore: NSObject {
|
|
|
44
48
|
internal var currentPlaylistId: String?
|
|
45
49
|
internal var currentTrackIndex: Int = -1
|
|
46
50
|
internal var currentTracks: [TrackItem] = []
|
|
47
|
-
|
|
51
|
+
// Bumped on every updatePlaylist request (playerQueue-owned). A queued rebuild
|
|
52
|
+
// whose generation is stale was superseded by a later request and is dropped,
|
|
53
|
+
// so a burst of playlist mutations collapses into a single rebuild.
|
|
54
|
+
internal var playlistUpdateGeneration: UInt64 = 0
|
|
48
55
|
internal var isManuallySeeked = false
|
|
49
56
|
internal var currentRepeatMode: RepeatMode = .off
|
|
50
57
|
internal var currentPlaybackSpeed: Double = 1.0
|
|
@@ -52,6 +59,10 @@ class TrackPlayerCore: NSObject {
|
|
|
52
59
|
internal var boundaryTimeObserver: Any?
|
|
53
60
|
internal var currentItemObservers: [NSKeyValueObservation] = []
|
|
54
61
|
|
|
62
|
+
// In-stream (timed) metadata output, attached to the current item only.
|
|
63
|
+
internal var metadataOutput: AVPlayerItemMetadataOutput?
|
|
64
|
+
internal weak var metadataOutputItem: AVPlayerItem?
|
|
65
|
+
|
|
55
66
|
// Gapless playback
|
|
56
67
|
internal var preloadedAssets: [String: AVURLAsset] = [:]
|
|
57
68
|
internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
@@ -103,6 +114,7 @@ class TrackPlayerCore: NSObject {
|
|
|
103
114
|
internal let onProgressListeners = ListenerRegistry<(Double, Double, Bool?) -> Void>()
|
|
104
115
|
internal let onTracksNeedUpdateListeners = ListenerRegistry<([TrackItem], Int) -> Void>()
|
|
105
116
|
internal let onTemporaryQueueChangeListeners = ListenerRegistry<([TrackItem], [TrackItem]) -> Void>()
|
|
117
|
+
internal let onTimedMetadataListeners = ListenerRegistry<(TimedMetadata) -> Void>()
|
|
106
118
|
internal let onCastStateChangeListeners = ListenerRegistry<(CastState, String?) -> Void>()
|
|
107
119
|
|
|
108
120
|
// MARK: - Google Cast
|
|
@@ -209,6 +221,13 @@ class TrackPlayerCore: NSObject {
|
|
|
209
221
|
onTemporaryQueueChangeListeners.remove(id: id)
|
|
210
222
|
}
|
|
211
223
|
|
|
224
|
+
@discardableResult func addOnTimedMetadataListener(_ cb: @escaping (TimedMetadata) -> Void) -> Int64 {
|
|
225
|
+
onTimedMetadataListeners.add(cb)
|
|
226
|
+
}
|
|
227
|
+
@discardableResult func removeOnTimedMetadataListener(id: Int64) -> Bool {
|
|
228
|
+
onTimedMetadataListeners.remove(id: id)
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
@discardableResult func addOnCastStateChangeListener(_ cb: @escaping (CastState, String?) -> Void) -> Int64 {
|
|
213
232
|
onCastStateChangeListeners.add(cb)
|
|
214
233
|
}
|
|
@@ -230,6 +249,7 @@ class TrackPlayerCore: NSObject {
|
|
|
230
249
|
p.removeTimeObserver(obs)
|
|
231
250
|
}
|
|
232
251
|
self.currentItemObservers.removeAll()
|
|
252
|
+
self.detachMetadataOutput()
|
|
233
253
|
if let p = self.player {
|
|
234
254
|
p.removeObserver(self, forKeyPath: "status")
|
|
235
255
|
p.removeObserver(self, forKeyPath: "rate")
|
|
@@ -239,6 +259,7 @@ class TrackPlayerCore: NSObject {
|
|
|
239
259
|
NotificationCenter.default.removeObserver(self)
|
|
240
260
|
self.pathMonitor?.cancel()
|
|
241
261
|
self.pathMonitor = nil
|
|
262
|
+
for asset in self.preloadedAssets.values { asset.cancelLoading() }
|
|
242
263
|
self.preloadedAssets.removeAll()
|
|
243
264
|
self.failedItemRetryCounts.removeAll()
|
|
244
265
|
self.redirectResolver.clear()
|
|
@@ -285,6 +285,7 @@ extension TrackPlayerCore {
|
|
|
285
285
|
|
|
286
286
|
guard let player, let currentItem = player.currentItem else {
|
|
287
287
|
NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
|
|
288
|
+
detachMetadataOutput()
|
|
288
289
|
// Queue exhausted — handle PLAYLIST repeat
|
|
289
290
|
if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = self.player {
|
|
290
291
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
|
|
@@ -292,7 +293,8 @@ extension TrackPlayerCore {
|
|
|
292
293
|
upNextQueue.removeAll()
|
|
293
294
|
currentTemporaryType = .none
|
|
294
295
|
|
|
295
|
-
let
|
|
296
|
+
let windowed = currentTracks.prefix(1 + Constants.queueWindowSize)
|
|
297
|
+
let allItems = windowed.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
|
|
296
298
|
var lastItem: AVPlayerItem? = nil
|
|
297
299
|
for item in allItems {
|
|
298
300
|
player.insert(item, after: lastItem)
|
|
@@ -399,14 +401,47 @@ extension TrackPlayerCore {
|
|
|
399
401
|
setupBoundaryTimeObserver()
|
|
400
402
|
}
|
|
401
403
|
|
|
404
|
+
// Top up the windowed AVQueuePlayer — the item we just consumed freed a slot.
|
|
405
|
+
// Only when it is actually short: rebuildAVQueueFromCurrentPosition can fall into
|
|
406
|
+
// rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
|
|
407
|
+
// replaces the current item and re-enters this callback. The length check makes any
|
|
408
|
+
// such re-entry a no-op and terminate.
|
|
409
|
+
if player.items().count - 1 < Constants.queueWindowSize {
|
|
410
|
+
rebuildAVQueueFromCurrentPosition()
|
|
411
|
+
}
|
|
412
|
+
|
|
402
413
|
// Preload upcoming tracks for gapless playback
|
|
403
414
|
preloadUpcomingTracks(from: currentTrackIndex + 1)
|
|
404
415
|
cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
|
|
405
416
|
}
|
|
406
417
|
|
|
418
|
+
/// Attaches an `AVPlayerItemMetadataOutput` to the current item so ICY/ID3
|
|
419
|
+
/// metadata emitted mid-stream reaches JS. Detaches from the previous item first —
|
|
420
|
+
/// an output may only be attached to one item at a time.
|
|
421
|
+
func attachMetadataOutput(to item: AVPlayerItem) {
|
|
422
|
+
detachMetadataOutput()
|
|
423
|
+
let output = AVPlayerItemMetadataOutput(identifiers: nil)
|
|
424
|
+
output.setDelegate(self, queue: playerQueue)
|
|
425
|
+
item.add(output)
|
|
426
|
+
metadataOutput = output
|
|
427
|
+
metadataOutputItem = item
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/// Clears the metadata output so a detached one can no longer pass the identity
|
|
431
|
+
/// check in the delegate and emit metadata for an item that is no longer playing.
|
|
432
|
+
func detachMetadataOutput() {
|
|
433
|
+
if let output = metadataOutput, let item = metadataOutputItem {
|
|
434
|
+
item.remove(output)
|
|
435
|
+
}
|
|
436
|
+
metadataOutput = nil
|
|
437
|
+
metadataOutputItem = nil
|
|
438
|
+
}
|
|
439
|
+
|
|
407
440
|
func setupCurrentItemObservers(item: AVPlayerItem) {
|
|
408
441
|
NitroPlayerLogger.log("TrackPlayerCore", "📱 Setting up item observers")
|
|
409
442
|
|
|
443
|
+
attachMetadataOutput(to: item)
|
|
444
|
+
|
|
410
445
|
let statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
|
|
411
446
|
self?.playerQueue.async {
|
|
412
447
|
if item.status == .readyToPlay {
|
|
@@ -421,11 +456,12 @@ extension TrackPlayerCore {
|
|
|
421
456
|
self?.player?.automaticallyWaitsToMinimizeStalling = false
|
|
422
457
|
// Update now playing info now that duration is available (capture on playerQueue first)
|
|
423
458
|
let state = self?.getStateInternal()
|
|
424
|
-
let
|
|
459
|
+
let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
|
|
425
460
|
let track = self?.getCurrentTrack()
|
|
426
461
|
DispatchQueue.main.async {
|
|
427
462
|
if let track = track, let state = state {
|
|
428
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
463
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
464
|
+
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
429
465
|
}
|
|
430
466
|
}
|
|
431
467
|
} else if item.status == .failed {
|
|
@@ -438,9 +474,13 @@ extension TrackPlayerCore {
|
|
|
438
474
|
currentItemObservers.append(statusObserver)
|
|
439
475
|
|
|
440
476
|
let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
|
|
441
|
-
|
|
477
|
+
guard item.isPlaybackBufferEmpty else { return }
|
|
478
|
+
// KVO fires on an arbitrary thread — emitStateChange reads playerQueue-owned
|
|
479
|
+
// state (currentTracks, temp queues, isRecoveringFromStall), so hop first.
|
|
480
|
+
self?.playerQueue.async {
|
|
481
|
+
guard let self, self.player?.currentItem === item else { return }
|
|
442
482
|
NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
|
|
443
|
-
self
|
|
483
|
+
self.emitStateChange()
|
|
444
484
|
}
|
|
445
485
|
}
|
|
446
486
|
currentItemObservers.append(bufferEmptyObserver)
|
|
@@ -466,6 +506,31 @@ extension TrackPlayerCore {
|
|
|
466
506
|
currentItemObservers.append(bufferKeepUpObserver)
|
|
467
507
|
}
|
|
468
508
|
|
|
509
|
+
func handleTimedMetadataGroups(_ groups: [AVTimedMetadataGroup]) {
|
|
510
|
+
var title: String?
|
|
511
|
+
var artist: String?
|
|
512
|
+
var album: String?
|
|
513
|
+
|
|
514
|
+
for group in groups {
|
|
515
|
+
for item in group.items {
|
|
516
|
+
guard let value = item.stringValue, !value.isEmpty else { continue }
|
|
517
|
+
switch item.commonKey {
|
|
518
|
+
case .some(.commonKeyTitle): title = value
|
|
519
|
+
case .some(.commonKeyArtist): artist = value
|
|
520
|
+
case .some(.commonKeyAlbumName): album = value
|
|
521
|
+
default:
|
|
522
|
+
// ICY/Shoutcast streams carry no common keys — match on the identifier
|
|
523
|
+
if item.identifier == .icyMetadataStreamTitle { title = value }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
guard title != nil || artist != nil || album != nil else { return }
|
|
529
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🏷️ Timed metadata - \(title ?? "-") / \(artist ?? "-")")
|
|
530
|
+
// artworkUrl is Android-only: iOS delivers embedded art as raw data, not a URL
|
|
531
|
+
notifyTimedMetadata(TimedMetadata(title: title, artist: artist, album: album, artworkUrl: nil))
|
|
532
|
+
}
|
|
533
|
+
|
|
469
534
|
func emitStateChange(reason: Reason? = nil) {
|
|
470
535
|
guard let player else { return }
|
|
471
536
|
let state: TrackPlayerState
|
|
@@ -486,3 +551,17 @@ extension TrackPlayerCore {
|
|
|
486
551
|
notifyPlaybackStateChange(state, reason)
|
|
487
552
|
}
|
|
488
553
|
}
|
|
554
|
+
|
|
555
|
+
extension TrackPlayerCore: AVPlayerItemMetadataOutputPushDelegate {
|
|
556
|
+
// Delivered on playerQueue (set in attachMetadataOutput)
|
|
557
|
+
func metadataOutput(
|
|
558
|
+
_ output: AVPlayerItemMetadataOutput,
|
|
559
|
+
didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
|
|
560
|
+
from track: AVPlayerItemTrack?
|
|
561
|
+
) {
|
|
562
|
+
// A detached output can still flush metadata it had already buffered — ignore it,
|
|
563
|
+
// it belongs to an item that is no longer current.
|
|
564
|
+
guard output === metadataOutput else { return }
|
|
565
|
+
handleTimedMetadataGroups(groups)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
@@ -13,22 +13,24 @@ extension TrackPlayerCore {
|
|
|
13
13
|
// Called on playerQueue — invoke listeners directly (Nitro handles JS thread hop)
|
|
14
14
|
func notifyTrackChange(_ track: TrackItem, _ reason: Reason?) {
|
|
15
15
|
onChangeTrackListeners.forEach { $0(track, reason) }
|
|
16
|
-
// Capture state + queue now (on playerQueue), pass pre-computed values to main
|
|
16
|
+
// Capture state + queue metrics now (on playerQueue), pass pre-computed values to main.
|
|
17
|
+
// Metrics, not the queue itself — the media session only needs count and position.
|
|
17
18
|
let state = getStateInternal()
|
|
18
|
-
let
|
|
19
|
+
let metrics = actualQueueMetrics()
|
|
19
20
|
DispatchQueue.main.async { [weak self] in
|
|
20
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
21
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
22
|
+
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
|
|
25
27
|
onPlaybackStateChangeListeners.forEach { $0(state, reason) }
|
|
28
|
+
guard let track = getCurrentTrack() else { return }
|
|
26
29
|
let playerState = getStateInternal()
|
|
27
|
-
let
|
|
28
|
-
let track = getCurrentTrack()
|
|
30
|
+
let metrics = actualQueueMetrics()
|
|
29
31
|
DispatchQueue.main.async { [weak self] in
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
33
|
+
track: track, state: playerState, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -44,6 +46,10 @@ extension TrackPlayerCore {
|
|
|
44
46
|
onTracksNeedUpdateListeners.forEach { $0(tracks, lookahead) }
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
func notifyTimedMetadata(_ metadata: TimedMetadata) {
|
|
50
|
+
onTimedMetadataListeners.forEach { $0(metadata) }
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
func notifyTemporaryQueueChange() {
|
|
48
54
|
let pn = playNextStack
|
|
49
55
|
let un = upNextQueue
|
|
@@ -11,78 +11,98 @@ import MediaPlayer
|
|
|
11
11
|
|
|
12
12
|
extension TrackPlayerCore {
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// MARK: - Public commands
|
|
15
|
+
//
|
|
16
|
+
// Each command has a `…OnQueue` form that MUST run on `playerQueue` and an `async`
|
|
17
|
+
// shim for callers that are not already there (media-session remote commands).
|
|
18
|
+
// HybridTrackPlayer dispatches the `…OnQueue` form directly from the JS thread so
|
|
19
|
+
// the serial queue's FIFO order equals the JS call order — see HybridTrackPlayer.
|
|
20
|
+
//
|
|
21
|
+
// Reading `isCasting` inside the queue block (not before it) keeps every cast-vs-local
|
|
22
|
+
// decision on the thread that owns that flag.
|
|
23
|
+
|
|
24
|
+
func playOnQueue() {
|
|
15
25
|
if isCasting {
|
|
16
26
|
intendedToPlay = true
|
|
17
27
|
castManager?.play()
|
|
18
28
|
return
|
|
19
29
|
}
|
|
20
|
-
|
|
30
|
+
playInternal()
|
|
21
31
|
}
|
|
32
|
+
func play() async { await withPlayerQueueNoThrow { self.playOnQueue() } }
|
|
22
33
|
|
|
23
|
-
func
|
|
34
|
+
func pauseOnQueue() {
|
|
24
35
|
if isCasting {
|
|
25
36
|
intendedToPlay = false
|
|
26
37
|
castManager?.pause()
|
|
27
38
|
return
|
|
28
39
|
}
|
|
29
|
-
|
|
40
|
+
pauseInternal()
|
|
30
41
|
}
|
|
42
|
+
func pause() async { await withPlayerQueueNoThrow { self.pauseOnQueue() } }
|
|
31
43
|
|
|
32
|
-
func
|
|
44
|
+
func seekOnQueue(position: Double) {
|
|
33
45
|
if isCasting {
|
|
34
46
|
castManager?.seek(to: position)
|
|
35
47
|
return
|
|
36
48
|
}
|
|
37
|
-
|
|
49
|
+
seekInternal(position: position)
|
|
50
|
+
}
|
|
51
|
+
func seek(position: Double) async {
|
|
52
|
+
await withPlayerQueueNoThrow { self.seekOnQueue(position: position) }
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
func
|
|
55
|
+
func skipToNextOnQueue() {
|
|
41
56
|
if isCasting {
|
|
42
57
|
castManager?.skipToNext()
|
|
43
58
|
return
|
|
44
59
|
}
|
|
45
|
-
|
|
60
|
+
skipToNextInternal()
|
|
46
61
|
}
|
|
62
|
+
func skipToNext() async { await withPlayerQueueNoThrow { self.skipToNextOnQueue() } }
|
|
47
63
|
|
|
48
|
-
func
|
|
64
|
+
func skipToPreviousOnQueue() {
|
|
49
65
|
if isCasting {
|
|
50
66
|
// The receiver queue starts at the current track, so queuePreviousItem has nothing behind it — use core state instead.
|
|
51
|
-
|
|
67
|
+
skipToPreviousCastInternal()
|
|
52
68
|
return
|
|
53
69
|
}
|
|
54
|
-
|
|
70
|
+
skipToPreviousInternal()
|
|
55
71
|
}
|
|
72
|
+
func skipToPrevious() async { await withPlayerQueueNoThrow { self.skipToPreviousOnQueue() } }
|
|
56
73
|
|
|
74
|
+
func setRepeatModeOnQueue(mode: RepeatMode) {
|
|
75
|
+
currentRepeatMode = mode
|
|
76
|
+
player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
77
|
+
if isCasting { castManager?.setQueueRepeatMode(mode) }
|
|
78
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
79
|
+
}
|
|
57
80
|
func setRepeatMode(mode: RepeatMode) async {
|
|
58
|
-
await withPlayerQueueNoThrow {
|
|
59
|
-
self.currentRepeatMode = mode
|
|
60
|
-
self.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
61
|
-
if self.isCasting { self.castManager?.setQueueRepeatMode(mode) }
|
|
62
|
-
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
63
|
-
}
|
|
81
|
+
await withPlayerQueueNoThrow { self.setRepeatModeOnQueue(mode: mode) }
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
func
|
|
84
|
+
func setVolumeOnQueue(volume: Double) {
|
|
85
|
+
let clamped = max(0.0, min(100.0, volume))
|
|
67
86
|
if isCasting {
|
|
68
|
-
let clamped = max(0.0, min(100.0, volume))
|
|
69
87
|
castManager?.setVolume(Float(clamped / 100.0))
|
|
70
88
|
return
|
|
71
89
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
let normalized = Float(clamped / 100.0)
|
|
91
|
+
player?.volume = normalized
|
|
92
|
+
NitroPlayerLogger.log(
|
|
93
|
+
"TrackPlayerCore", "🔊 Volume set to \(Int(clamped))% (normalized: \(normalized))")
|
|
94
|
+
}
|
|
95
|
+
func setVolume(volume: Double) async {
|
|
96
|
+
await withPlayerQueueNoThrow { self.setVolumeOnQueue(volume: volume) }
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
func
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
func configureOnQueue(
|
|
100
|
+
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
101
|
+
showInNotification: Bool?, lookaheadCount: Int?
|
|
102
|
+
) {
|
|
103
|
+
if let la = lookaheadCount {
|
|
104
|
+
self.lookaheadCount = la
|
|
105
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
|
|
86
106
|
}
|
|
87
107
|
DispatchQueue.main.async { [weak self] in
|
|
88
108
|
self?.mediaSessionManager?.configure(
|
|
@@ -92,21 +112,31 @@ extension TrackPlayerCore {
|
|
|
92
112
|
)
|
|
93
113
|
}
|
|
94
114
|
}
|
|
115
|
+
func configure(
|
|
116
|
+
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
117
|
+
showInNotification: Bool?, lookaheadCount: Int?
|
|
118
|
+
) async {
|
|
119
|
+
await withPlayerQueueNoThrow {
|
|
120
|
+
self.configureOnQueue(
|
|
121
|
+
androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
|
|
122
|
+
showInNotification: showInNotification, lookaheadCount: lookaheadCount)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
95
125
|
|
|
96
|
-
func
|
|
126
|
+
func setPlaybackSpeedOnQueue(_ speed: Double) {
|
|
127
|
+
currentPlaybackSpeed = speed
|
|
97
128
|
if isCasting {
|
|
98
|
-
currentPlaybackSpeed = speed
|
|
99
129
|
castManager?.setPlaybackRate(Float(speed))
|
|
100
130
|
return
|
|
101
131
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if let player = self.player, player.rate != 0 {
|
|
106
|
-
player.rate = Float(speed)
|
|
107
|
-
}
|
|
132
|
+
// Only update rate if currently playing; pause keeps rate at 0 until play() is called
|
|
133
|
+
if let player = self.player, player.rate != 0 {
|
|
134
|
+
player.rate = Float(speed)
|
|
108
135
|
}
|
|
109
136
|
}
|
|
137
|
+
func setPlaybackSpeed(_ speed: Double) async {
|
|
138
|
+
await withPlayerQueueNoThrow { self.setPlaybackSpeedOnQueue(speed) }
|
|
139
|
+
}
|
|
110
140
|
|
|
111
141
|
func getPlaybackSpeed() async -> Double {
|
|
112
142
|
await withPlayerQueueNoThrow { self.currentPlaybackSpeed }
|
|
@@ -202,6 +232,12 @@ extension TrackPlayerCore {
|
|
|
202
232
|
}
|
|
203
233
|
}
|
|
204
234
|
|
|
235
|
+
// The AVQueuePlayer is windowed, so items().count is not the logical queue length.
|
|
236
|
+
// Top up first so there is always something to advance to while tracks remain.
|
|
237
|
+
if hasUpcomingTrack() {
|
|
238
|
+
if queuePlayer.items().count <= 1 { rebuildAVQueueFromCurrentPosition() }
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
if queuePlayer.items().count > 1 {
|
|
206
242
|
queuePlayer.advanceToNextItem()
|
|
207
243
|
} else {
|