react-native-nitro-player 0.5.5 → 0.5.7

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 (36) hide show
  1. package/README.md +2 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +43 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +8 -2
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +345 -4
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +43 -10
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +76 -7
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/storage/NitroPlayerStorage.kt +9 -2
  8. package/ios/HybridTrackPlayer.swift +54 -1
  9. package/ios/core/TrackPlayerCore.swift +254 -2
  10. package/ios/download/DownloadDatabase.swift +79 -2
  11. package/ios/download/DownloadManagerCore.swift +81 -2
  12. package/ios/playlist/PlaylistManager.swift +68 -0
  13. package/lib/specs/TrackPlayer.nitro.d.ts +47 -0
  14. package/lib/types/PlayerQueue.d.ts +5 -0
  15. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  16. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double.hpp +104 -0
  17. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +160 -0
  18. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +8 -0
  19. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +7 -3
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__double.kt +80 -0
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +37 -0
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  23. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +16 -0
  24. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +65 -0
  25. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +62 -0
  26. package/nitrogen/generated/ios/swift/Func_void_double.swift +47 -0
  27. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double.swift +47 -0
  28. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +8 -0
  29. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +173 -0
  30. package/nitrogen/generated/ios/swift/PlayerConfig.swift +24 -1
  31. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
  32. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +8 -0
  33. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +6 -2
  34. package/package.json +1 -1
  35. package/src/specs/TrackPlayer.nitro.ts +57 -0
  36. package/src/types/PlayerQueue.ts +5 -0
@@ -302,6 +302,66 @@ class PlaylistManager private constructor(
302
302
  return true
303
303
  }
304
304
 
305
+ /**
306
+ * Update entire track objects across all playlists
307
+ * Matches by track.id and replaces the entire track object
308
+ * @param tracks List of full TrackItem objects to update
309
+ * @return Map of playlistId -> count of tracks updated
310
+ */
311
+ fun updateTracks(tracks: List<TrackItem>): Map<String, Int> {
312
+ val tracksMap = tracks.associateBy { it.id }
313
+ val affectedPlaylists = mutableMapOf<String, Int>()
314
+
315
+ synchronized(playlists) {
316
+ playlists.forEach { (playlistId, playlist) ->
317
+ var updateCount = 0
318
+ val newTracks =
319
+ playlist.tracks
320
+ .map { track ->
321
+ tracksMap[track.id]?.also { updateCount++ } ?: track
322
+ }.toMutableList()
323
+
324
+ if (updateCount > 0) {
325
+ affectedPlaylists[playlistId] = updateCount
326
+ playlists[playlistId] = playlist.copy(tracks = newTracks)
327
+ }
328
+ }
329
+ }
330
+
331
+ if (affectedPlaylists.isNotEmpty()) {
332
+ scheduleSave()
333
+ affectedPlaylists.keys.forEach { playlistId ->
334
+ notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
335
+ }
336
+ notifyPlaylistsChanged(QueueOperation.UPDATE)
337
+ }
338
+
339
+ return affectedPlaylists
340
+ }
341
+
342
+ /**
343
+ * Get tracks by IDs from all playlists
344
+ * @param trackIds List of track IDs to fetch
345
+ * @return List of matching TrackItem objects
346
+ */
347
+ fun getTracksById(trackIds: List<String>): List<TrackItem> {
348
+ val trackIdSet = trackIds.toSet()
349
+ val foundTracks = mutableMapOf<String, TrackItem>()
350
+
351
+ synchronized(playlists) {
352
+ playlists.values.forEach { playlist ->
353
+ playlist.tracks.forEach { track ->
354
+ if (trackIdSet.contains(track.id) && !foundTracks.containsKey(track.id)) {
355
+ foundTracks[track.id] = track
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ // Return in same order as requested
362
+ return trackIds.mapNotNull { foundTracks[it] }
363
+ }
364
+
305
365
  /**
306
366
  * Load a playlist for playback (sets it as current)
307
367
  */
@@ -405,10 +465,11 @@ class PlaylistManager private constructor(
405
465
  }
406
466
  }
407
467
 
408
- val wrapper = JSONObject().apply {
409
- put("playlists", jsonArray)
410
- put("currentPlaylistId", currentPlaylistId)
411
- }
468
+ val wrapper =
469
+ JSONObject().apply {
470
+ put("playlists", jsonArray)
471
+ put("currentPlaylistId", currentPlaylistId)
472
+ }
412
473
  NitroPlayerStorage.write(context, "playlists.json", wrapper.toString())
413
474
  } catch (e: Exception) {
414
475
  e.printStackTrace()
@@ -423,8 +484,12 @@ class PlaylistManager private constructor(
423
484
  val wrapper = JSONObject(json)
424
485
  val jsonArray = wrapper.optJSONArray("playlists") ?: JSONArray()
425
486
  parseAndLoadPlaylists(jsonArray)
426
- currentPlaylistId = if (wrapper.isNull("currentPlaylistId")) null
427
- else wrapper.optString("currentPlaylistId", null.toString()).takeIf { it != "null" }
487
+ currentPlaylistId =
488
+ if (wrapper.isNull("currentPlaylistId")) {
489
+ null
490
+ } else {
491
+ wrapper.optString("currentPlaylistId", null.toString()).takeIf { it != "null" }
492
+ }
428
493
  } catch (e: Exception) {
429
494
  e.printStackTrace()
430
495
  }
@@ -440,7 +505,11 @@ class PlaylistManager private constructor(
440
505
  parseAndLoadPlaylists(jsonArray)
441
506
  currentPlaylistId = prefs.getString("currentPlaylistId", null)
442
507
  // Remove old SharedPreferences data to free space
443
- prefs.edit().remove("playlists").remove("currentPlaylistId").apply()
508
+ prefs
509
+ .edit()
510
+ .remove("playlists")
511
+ .remove("currentPlaylistId")
512
+ .apply()
444
513
  // Persist in new format
445
514
  saveToFile()
446
515
  } catch (e: Exception) {
@@ -9,7 +9,10 @@ object NitroPlayerStorage {
9
9
  private const val DIR_NAME = "nitroplayer"
10
10
 
11
11
  /** Reads the contents of [filename] from the NitroPlayer storage directory, or null if absent. */
12
- fun read(context: Context, filename: String): String? {
12
+ fun read(
13
+ context: Context,
14
+ filename: String,
15
+ ): String? {
13
16
  val file = File(storageDirectory(context), filename)
14
17
  return if (file.exists()) {
15
18
  try {
@@ -28,7 +31,11 @@ object NitroPlayerStorage {
28
31
  * Writes to `<filename>.tmp` first, then renames — leaving the prior file
29
32
  * untouched on failure (crash-safe).
30
33
  */
31
- fun write(context: Context, filename: String, json: String) {
34
+ fun write(
35
+ context: Context,
36
+ filename: String,
37
+ json: String,
38
+ ) {
32
39
  try {
33
40
  val dir = storageDirectory(context)
34
41
  dir.mkdirs()
@@ -88,7 +88,8 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
88
88
  core.configure(
89
89
  androidAutoEnabled: config.androidAutoEnabled,
90
90
  carPlayEnabled: config.carPlayEnabled,
91
- showInNotification: config.showInNotification
91
+ showInNotification: config.showInNotification,
92
+ lookaheadCount: config.lookaheadCount.map { Int($0) }
92
93
  )
93
94
  }
94
95
 
@@ -139,4 +140,56 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
139
140
  func setVolume(volume: Double) throws -> Bool {
140
141
  return core.setVolume(volume: volume)
141
142
  }
143
+
144
+ // MARK: - Lazy URL Loading
145
+
146
+ func updateTracks(tracks: [TrackItem]) throws -> Promise<Void> {
147
+ return Promise.async {
148
+ self.core.updateTracks(tracks: tracks)
149
+ }
150
+ }
151
+
152
+ func getTracksById(trackIds: [String]) throws -> Promise<[TrackItem]> {
153
+ return Promise.async {
154
+ return self.core.getTracksById(trackIds: trackIds)
155
+ }
156
+ }
157
+
158
+ func getTracksNeedingUrls() throws -> Promise<[TrackItem]> {
159
+ return Promise.async {
160
+ return self.core.getTracksNeedingUrls()
161
+ }
162
+ }
163
+
164
+ func getNextTracks(count: Double) throws -> Promise<[TrackItem]> {
165
+ return Promise.async {
166
+ return self.core.getNextTracks(count: Int(count))
167
+ }
168
+ }
169
+
170
+ func getCurrentTrackIndex() throws -> Promise<Double> {
171
+ return Promise.async {
172
+ return Double(self.core.getCurrentTrackIndex())
173
+ }
174
+ }
175
+
176
+ func onTracksNeedUpdate(callback: @escaping ([TrackItem], Double) -> Void) throws {
177
+ core.addOnTracksNeedUpdateListener { tracks, lookahead in
178
+ callback(tracks, Double(lookahead))
179
+ }
180
+ }
181
+
182
+ func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
183
+ Promise.async{
184
+ self.core.setPlaybackSpeed(speed)
185
+ }
186
+
187
+ }
188
+
189
+ func getPlaybackSpeed() throws -> Promise<Double> {
190
+ return Promise.async{
191
+ return self.core.getPlaybackSpeed()
192
+ }
193
+
194
+ }
142
195
  }
@@ -50,6 +50,7 @@ class TrackPlayerCore: NSObject {
50
50
  private var currentTracks: [TrackItem] = []
51
51
  private var isManuallySeeked = false
52
52
  private var currentRepeatMode: RepeatMode = .off
53
+ private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
53
54
  private var boundaryTimeObserver: Any?
54
55
  private var currentItemObservers: [NSKeyValueObservation] = []
55
56
 
@@ -310,6 +311,9 @@ class TrackPlayerCore: NSObject {
310
311
  if let player = player {
311
312
  NitroPlayerLogger.log("TrackPlayerCore", "📋 Remaining items in queue: \(player.items().count)")
312
313
  }
314
+
315
+ // Check if upcoming tracks need URLs
316
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
313
317
  }
314
318
 
315
319
  @objc private func playerItemFailedToPlayToEndTime(notification: Notification) {
@@ -571,6 +575,14 @@ class TrackPlayerCore: NSObject {
571
575
  }
572
576
  }
573
577
  }
578
+
579
+ func setPlaybackSpeed(_ speed: Double) {
580
+ player?.rate = Float(speed)
581
+ }
582
+
583
+ func getPlaybackSpeed() -> Double {
584
+ return Double(player?.rate ?? 1.0)
585
+ }
574
586
 
575
587
  private func loadPlaylistInternal(playlistId: String) {
576
588
  NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
@@ -596,6 +608,9 @@ class TrackPlayerCore: NSObject {
596
608
  self.updatePlayerQueue(tracks: playlist.tracks)
597
609
  // Emit initial state (paused/stopped before play)
598
610
  self.emitStateChange()
611
+
612
+ // Check if upcoming tracks need URLs
613
+ self.checkUpcomingTracksForUrls(lookahead: lookaheadCount)
599
614
  } else {
600
615
  NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
601
616
  NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
@@ -1301,6 +1316,9 @@ class TrackPlayerCore: NSObject {
1301
1316
  queuePlayer.pause()
1302
1317
  self.notifyPlaybackStateChange(.stopped, .end)
1303
1318
  }
1319
+
1320
+ // Check if upcoming tracks need URLs
1321
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1304
1322
  }
1305
1323
 
1306
1324
  func skipToPrevious() {
@@ -1343,6 +1361,9 @@ class TrackPlayerCore: NSObject {
1343
1361
  // Already at first track, restart it
1344
1362
  queuePlayer.seek(to: .zero)
1345
1363
  }
1364
+
1365
+ // Check if upcoming tracks need URLs
1366
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1346
1367
  }
1347
1368
 
1348
1369
  func seek(position: Double) {
@@ -1469,9 +1490,14 @@ class TrackPlayerCore: NSObject {
1469
1490
  func configure(
1470
1491
  androidAutoEnabled: Bool?,
1471
1492
  carPlayEnabled: Bool?,
1472
- showInNotification: Bool?
1493
+ showInNotification: Bool?,
1494
+ lookaheadCount: Int? = nil
1473
1495
  ) {
1474
1496
  DispatchQueue.main.async { [weak self] in
1497
+ if let lookahead = lookaheadCount {
1498
+ self?.lookaheadCount = lookahead
1499
+ NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(lookahead)")
1500
+ }
1475
1501
  self?.mediaSessionManager?.configure(
1476
1502
  androidAutoEnabled: androidAutoEnabled,
1477
1503
  carPlayEnabled: carPlayEnabled,
@@ -1619,9 +1645,17 @@ class TrackPlayerCore: NSObject {
1619
1645
  upNextQueue.removeAll()
1620
1646
  currentTemporaryType = .none
1621
1647
 
1622
- return playFromIndexInternalWithResult(index: originalIndex)
1648
+ let result = playFromIndexInternalWithResult(index: originalIndex)
1649
+
1650
+ // Check if upcoming tracks need URLs
1651
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1652
+
1653
+ return result
1623
1654
  }
1624
1655
 
1656
+ // Check if upcoming tracks need URLs after any successful skip
1657
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1658
+
1625
1659
  return false
1626
1660
  }
1627
1661
 
@@ -1857,6 +1891,224 @@ class TrackPlayerCore: NSObject {
1857
1891
  return .none
1858
1892
  }
1859
1893
 
1894
+ // MARK: - Lazy URL Loading Support
1895
+
1896
+ /**
1897
+ * Update entire track objects and rebuild queue if needed
1898
+ * Skips currently playing track to preserve gapless playback
1899
+ * CRITICAL: Invalidates preloaded assets and re-preloads for gapless
1900
+ */
1901
+ func updateTracks(tracks: [TrackItem]) {
1902
+ DispatchQueue.main.async { [weak self] in
1903
+ guard let self = self else { return }
1904
+
1905
+ NitroPlayerLogger.log("TrackPlayerCore", "🔄 updateTracks: \(tracks.count) updates")
1906
+
1907
+ // Get current track ID to avoid updating it (preserves gapless playback)
1908
+ let currentTrackId = self.getCurrentTrack()?.id
1909
+
1910
+ // Filter out current track and validate
1911
+ let safeTracks = tracks.filter { track in
1912
+ switch true {
1913
+ case track.id == currentTrackId:
1914
+ NitroPlayerLogger.log(
1915
+ "TrackPlayerCore",
1916
+ "⚠️ Skipping update for currently playing track: \(track.id) (preserves gapless)")
1917
+ return false
1918
+ case track.url.isEmpty:
1919
+ NitroPlayerLogger.log(
1920
+ "TrackPlayerCore", "⚠️ Skipping track with empty URL: \(track.id)")
1921
+ return false
1922
+ default:
1923
+ return true
1924
+ }
1925
+ }
1926
+
1927
+ guard !safeTracks.isEmpty else {
1928
+ NitroPlayerLogger.log("TrackPlayerCore", "✅ No valid updates to apply")
1929
+ return
1930
+ }
1931
+
1932
+ // Invalidate preloaded assets for tracks with updated data
1933
+ // This is CRITICAL for gapless playback - old assets might use old URLs
1934
+ let updatedTrackIds = Set(safeTracks.map { $0.id })
1935
+ for trackId in updatedTrackIds {
1936
+ if self.preloadedAssets[trackId] != nil {
1937
+ NitroPlayerLogger.log(
1938
+ "TrackPlayerCore", "🗑️ Invalidating preloaded asset for track: \(trackId)")
1939
+ self.preloadedAssets.removeValue(forKey: trackId)
1940
+ }
1941
+ }
1942
+
1943
+ // Update in PlaylistManager
1944
+ let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
1945
+
1946
+ // Rebuild queue if current playlist was affected
1947
+ if let currentId = self.currentPlaylistId,
1948
+ let updateCount = affectedPlaylists[currentId]
1949
+ {
1950
+ NitroPlayerLogger.log(
1951
+ "TrackPlayerCore",
1952
+ "🔄 Rebuilding queue - \(updateCount) tracks updated in current playlist")
1953
+
1954
+ // This method preserves current item
1955
+ self.rebuildAVQueueFromCurrentPosition()
1956
+
1957
+ // Re-preload upcoming tracks for gapless playback
1958
+ // CRITICAL: This restores gapless buffering after queue rebuild
1959
+ self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
1960
+
1961
+ NitroPlayerLogger.log("TrackPlayerCore", "✅ Queue rebuilt, gapless playback preserved")
1962
+ }
1963
+
1964
+ NitroPlayerLogger.log(
1965
+ "TrackPlayerCore",
1966
+ "✅ Track updates complete - \(affectedPlaylists.count) playlists affected")
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * Get tracks by IDs from all playlists
1972
+ */
1973
+ func getTracksById(trackIds: [String]) -> [TrackItem] {
1974
+ if Thread.isMainThread {
1975
+ return playlistManager.getTracksById(trackIds: trackIds)
1976
+ } else {
1977
+ var tracks: [TrackItem] = []
1978
+ DispatchQueue.main.sync { [weak self] in
1979
+ tracks = self?.playlistManager.getTracksById(trackIds: trackIds) ?? []
1980
+ }
1981
+ return tracks
1982
+ }
1983
+ }
1984
+
1985
+ /**
1986
+ * Get tracks needing URLs from current playlist
1987
+ */
1988
+ func getTracksNeedingUrls() -> [TrackItem] {
1989
+ if Thread.isMainThread {
1990
+ return getTracksNeedingUrlsInternal()
1991
+ } else {
1992
+ var tracks: [TrackItem] = []
1993
+ DispatchQueue.main.sync { [weak self] in
1994
+ tracks = self?.getTracksNeedingUrlsInternal() ?? []
1995
+ }
1996
+ return tracks
1997
+ }
1998
+ }
1999
+
2000
+ private func getTracksNeedingUrlsInternal() -> [TrackItem] {
2001
+ guard let currentId = currentPlaylistId,
2002
+ let playlist = playlistManager.getPlaylist(playlistId: currentId)
2003
+ else {
2004
+ return []
2005
+ }
2006
+
2007
+ return playlist.tracks.filter { $0.url.isEmpty }
2008
+ }
2009
+
2010
+ /**
2011
+ * Get next N tracks from current position
2012
+ */
2013
+ func getNextTracks(count: Int) -> [TrackItem] {
2014
+ if Thread.isMainThread {
2015
+ return getNextTracksInternal(count: count)
2016
+ } else {
2017
+ var tracks: [TrackItem] = []
2018
+ DispatchQueue.main.sync { [weak self] in
2019
+ tracks = self?.getNextTracksInternal(count: count) ?? []
2020
+ }
2021
+ return tracks
2022
+ }
2023
+ }
2024
+
2025
+ private func getNextTracksInternal(count: Int) -> [TrackItem] {
2026
+ let actualQueue = getActualQueueInternal()
2027
+ guard !actualQueue.isEmpty else { return [] }
2028
+
2029
+ guard let currentTrack = getCurrentTrack(),
2030
+ let currentIndex = actualQueue.firstIndex(where: { $0.id == currentTrack.id })
2031
+ else {
2032
+ return []
2033
+ }
2034
+
2035
+ let startIndex = currentIndex + 1
2036
+ let endIndex = min(startIndex + count, actualQueue.count)
2037
+
2038
+ return startIndex < actualQueue.count ? Array(actualQueue[startIndex..<endIndex]) : []
2039
+ }
2040
+
2041
+ /**
2042
+ * Get current track index in playlist
2043
+ */
2044
+ func getCurrentTrackIndex() -> Int {
2045
+ if Thread.isMainThread {
2046
+ return currentTrackIndex
2047
+ } else {
2048
+ var index = -1
2049
+ DispatchQueue.main.sync { [weak self] in
2050
+ index = self?.currentTrackIndex ?? -1
2051
+ }
2052
+ return index
2053
+ }
2054
+ }
2055
+
2056
+ /**
2057
+ * Callback for tracks needing update
2058
+ */
2059
+ typealias OnTracksNeedUpdateCallback = ([TrackItem], Int) -> Void
2060
+
2061
+ // Add to class properties
2062
+ private var onTracksNeedUpdateListeners: [(callback: OnTracksNeedUpdateCallback, isAlive: Bool)] =
2063
+ []
2064
+ private let tracksNeedUpdateQueue = DispatchQueue(
2065
+ label: "com.nitroplayer.tracksneedupdate", attributes: .concurrent)
2066
+
2067
+ /**
2068
+ * Register listener for when tracks need update
2069
+ */
2070
+ func addOnTracksNeedUpdateListener(callback: @escaping OnTracksNeedUpdateCallback) {
2071
+ tracksNeedUpdateQueue.async(flags: .barrier) { [weak self] in
2072
+ self?.onTracksNeedUpdateListeners.append((callback: callback, isAlive: true))
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * Notify listeners that tracks need updating
2078
+ */
2079
+ private func notifyTracksNeedUpdate(tracks: [TrackItem], lookahead: Int) {
2080
+ tracksNeedUpdateQueue.async(flags: .barrier) { [weak self] in
2081
+ guard let self = self else { return }
2082
+
2083
+ // Clean up dead listeners
2084
+ self.onTracksNeedUpdateListeners.removeAll { !$0.isAlive }
2085
+ let liveCallbacks = self.onTracksNeedUpdateListeners.map { $0.callback }
2086
+
2087
+ if !liveCallbacks.isEmpty {
2088
+ DispatchQueue.main.async {
2089
+ for callback in liveCallbacks {
2090
+ callback(tracks, lookahead)
2091
+ }
2092
+ }
2093
+ }
2094
+ }
2095
+ }
2096
+
2097
+ /**
2098
+ * Check if upcoming tracks need URLs and notify listeners
2099
+ * Call this in playerItemDidPlayToEndTime or after skip operations
2100
+ */
2101
+ private func checkUpcomingTracksForUrls(lookahead: Int = 5) {
2102
+ let nextTracks = getNextTracksInternal(count: lookahead)
2103
+ let tracksNeedingUrls = nextTracks.filter { $0.url.isEmpty }
2104
+
2105
+ if !tracksNeedingUrls.isEmpty {
2106
+ NitroPlayerLogger.log(
2107
+ "TrackPlayerCore", "⚠️ \(tracksNeedingUrls.count) upcoming tracks need URLs")
2108
+ notifyTracksNeedUpdate(tracks: tracksNeedingUrls, lookahead: lookahead)
2109
+ }
2110
+ }
2111
+
1860
2112
  // MARK: - Cleanup
1861
2113
 
1862
2114
  deinit {
@@ -490,6 +490,11 @@ final class DownloadDatabase {
490
490
  }
491
491
 
492
492
  private func trackItemToRecord(_ track: TrackItem) -> TrackItemRecord {
493
+ var extraPayloadDict: [String: Any]? = nil
494
+ if let extraPayload = track.extraPayload {
495
+ extraPayloadDict = extraPayload.toDictionary()
496
+ }
497
+
493
498
  return TrackItemRecord(
494
499
  id: track.id,
495
500
  title: track.title,
@@ -497,11 +502,28 @@ final class DownloadDatabase {
497
502
  album: track.album,
498
503
  duration: track.duration,
499
504
  url: track.url,
500
- artwork: variantToString(track.artwork)
505
+ artwork: variantToString(track.artwork),
506
+ extraPayload: extraPayloadDict
501
507
  )
502
508
  }
503
509
 
504
510
  private func recordToTrackItem(_ record: TrackItemRecord) -> TrackItem {
511
+ var extraPayload: AnyMap? = nil
512
+ if let extraPayloadDict = record.extraPayload {
513
+ extraPayload = AnyMap()
514
+ for (key, value) in extraPayloadDict {
515
+ if let stringValue = value as? String {
516
+ extraPayload?.setString(key: key, value: stringValue)
517
+ } else if let doubleValue = value as? Double {
518
+ extraPayload?.setDouble(key: key, value: doubleValue)
519
+ } else if let intValue = value as? Int {
520
+ extraPayload?.setDouble(key: key, value: Double(intValue))
521
+ } else if let boolValue = value as? Bool {
522
+ extraPayload?.setBoolean(key: key, value: boolValue)
523
+ }
524
+ }
525
+ }
526
+
505
527
  return TrackItem(
506
528
  id: record.id,
507
529
  title: record.title,
@@ -510,7 +532,7 @@ final class DownloadDatabase {
510
532
  duration: record.duration,
511
533
  url: record.url,
512
534
  artwork: stringToVariant(record.artwork),
513
- extraPayload: nil
535
+ extraPayload: extraPayload
514
536
  )
515
537
  }
516
538
 
@@ -547,4 +569,59 @@ private struct TrackItemRecord: Codable {
547
569
  let duration: Double
548
570
  let url: String
549
571
  let artwork: String?
572
+ let extraPayload: [String: Any]?
573
+
574
+ enum CodingKeys: String, CodingKey {
575
+ case id, title, artist, album, duration, url, artwork, extraPayload
576
+ }
577
+
578
+ // Manual encoding to handle [String: Any]
579
+ func encode(to encoder: Encoder) throws {
580
+ var container = encoder.container(keyedBy: CodingKeys.self)
581
+ try container.encode(id, forKey: .id)
582
+ try container.encode(title, forKey: .title)
583
+ try container.encode(artist, forKey: .artist)
584
+ try container.encode(album, forKey: .album)
585
+ try container.encode(duration, forKey: .duration)
586
+ try container.encode(url, forKey: .url)
587
+ try container.encodeIfPresent(artwork, forKey: .artwork)
588
+
589
+ if let extraPayload = extraPayload {
590
+ let jsonData = try JSONSerialization.data(withJSONObject: extraPayload)
591
+ if let jsonString = String(data: jsonData, encoding: .utf8) {
592
+ try container.encode(jsonString, forKey: .extraPayload)
593
+ }
594
+ }
595
+ }
596
+
597
+ // Manual decoding to handle [String: Any]
598
+ init(from decoder: Decoder) throws {
599
+ let container = try decoder.container(keyedBy: CodingKeys.self)
600
+ id = try container.decode(String.self, forKey: .id)
601
+ title = try container.decode(String.self, forKey: .title)
602
+ artist = try container.decode(String.self, forKey: .artist)
603
+ album = try container.decode(String.self, forKey: .album)
604
+ duration = try container.decode(Double.self, forKey: .duration)
605
+ url = try container.decode(String.self, forKey: .url)
606
+ artwork = try container.decodeIfPresent(String.self, forKey: .artwork)
607
+
608
+ if let jsonString = try? container.decodeIfPresent(String.self, forKey: .extraPayload),
609
+ let jsonData = jsonString.data(using: .utf8) {
610
+ extraPayload = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
611
+ } else {
612
+ extraPayload = nil
613
+ }
614
+ }
615
+
616
+ // Initializer for code creation
617
+ init(id: String, title: String, artist: String, album: String, duration: Double, url: String, artwork: String?, extraPayload: [String: Any]?) {
618
+ self.id = id
619
+ self.title = title
620
+ self.artist = artist
621
+ self.album = album
622
+ self.duration = duration
623
+ self.url = url
624
+ self.artwork = artwork
625
+ self.extraPayload = extraPayload
626
+ }
550
627
  }