react-native-nitro-player 0.7.0 → 0.7.1-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.
Files changed (107) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +165 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +161 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +11 -58
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
  23. package/ios/HybridDownloadManager.swift +32 -26
  24. package/ios/HybridEqualizer.swift +48 -35
  25. package/ios/HybridTrackPlayer.swift +127 -102
  26. package/ios/core/ListenerRegistry.swift +60 -0
  27. package/ios/core/TrackPlayerCore.swift +130 -2356
  28. package/ios/core/TrackPlayerListener.swift +395 -0
  29. package/ios/core/TrackPlayerNotify.swift +52 -0
  30. package/ios/core/TrackPlayerPlayback.swift +274 -0
  31. package/ios/core/TrackPlayerQueue.swift +212 -0
  32. package/ios/core/TrackPlayerQueueBuild.swift +482 -0
  33. package/ios/core/TrackPlayerTempQueue.swift +167 -0
  34. package/ios/core/TrackPlayerUrlLoader.swift +169 -0
  35. package/ios/equalizer/EqualizerCore.swift +24 -89
  36. package/ios/media/MediaSessionManager.swift +32 -49
  37. package/ios/playlist/PlaylistManager.swift +2 -9
  38. package/ios/queue/HybridPlayerQueue.swift +69 -66
  39. package/lib/hooks/useDownloadedTracks.js +16 -13
  40. package/lib/hooks/useEqualizer.d.ts +4 -4
  41. package/lib/hooks/useEqualizer.js +12 -12
  42. package/lib/hooks/useEqualizerPresets.d.ts +3 -3
  43. package/lib/hooks/useEqualizerPresets.js +12 -18
  44. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
  45. package/lib/specs/AudioDevices.nitro.d.ts +2 -2
  46. package/lib/specs/DownloadManager.nitro.d.ts +10 -10
  47. package/lib/specs/Equalizer.nitro.d.ts +9 -9
  48. package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
  49. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  50. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
  51. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +31 -6
  52. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +2 -2
  53. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +16 -3
  54. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +1 -1
  55. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +154 -44
  56. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +10 -10
  57. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +130 -34
  58. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +9 -9
  59. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +115 -24
  60. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +8 -8
  61. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +243 -24
  62. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +16 -8
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +3 -2
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +2 -1
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +10 -10
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +10 -9
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +9 -8
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +45 -8
  70. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
  71. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
  72. package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
  73. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
  74. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
  75. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
  76. package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
  77. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
  78. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
  79. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
  80. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
  85. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
  86. package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
  87. package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
  88. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
  89. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
  90. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
  91. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
  92. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
  93. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
  94. package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
  95. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
  96. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
  97. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
  98. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
  99. package/package.json +1 -1
  100. package/src/hooks/useDownloadedTracks.ts +17 -13
  101. package/src/hooks/useEqualizer.ts +16 -16
  102. package/src/hooks/useEqualizerPresets.ts +15 -21
  103. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
  104. package/src/specs/AudioDevices.nitro.ts +2 -2
  105. package/src/specs/DownloadManager.nitro.ts +10 -10
  106. package/src/specs/Equalizer.nitro.ts +9 -9
  107. package/src/specs/TrackPlayer.nitro.ts +52 -16
@@ -2,9 +2,8 @@
2
2
  // TrackPlayerCore.swift
3
3
  // NitroPlayer
4
4
  //
5
- // Created by Ritesh Shukla on 10/12/25.
5
+ // Created by Ritesh Shukla on 25/03/26.
6
6
  //
7
-
8
7
  import AVFoundation
9
8
  import Foundation
10
9
  import MediaPlayer
@@ -13,113 +12,82 @@ import ObjectiveC
13
12
 
14
13
  class TrackPlayerCore: NSObject {
15
14
  // MARK: - Constants
16
-
17
- private enum Constants {
18
- // Time thresholds (in seconds)
15
+ enum Constants {
19
16
  static let skipToPreviousThreshold: Double = 2.0
20
17
  static let stateChangeDelay: TimeInterval = 0.1
21
-
22
- // Duration thresholds for boundary intervals (in seconds)
23
18
  static let twoHoursInSeconds: Double = 7200
24
19
  static let oneHourInSeconds: Double = 3600
25
-
26
- // Boundary time intervals (in seconds)
27
- static let boundaryIntervalLong: Double = 5.0 // For tracks > 2 hours
28
- static let boundaryIntervalMedium: Double = 2.0 // For tracks > 1 hour
29
- static let boundaryIntervalDefault: Double = 1.0 // Default interval
30
-
31
- // UI/Display constants
20
+ static let boundaryIntervalLong: Double = 5.0
21
+ static let boundaryIntervalMedium: Double = 2.0
22
+ static let boundaryIntervalDefault: Double = 1.0
32
23
  static let separatorLineLength: Int = 80
33
24
  static let playlistSeparatorLength: Int = 40
34
-
35
- // Gapless playback configuration
36
- static let preferredForwardBufferDuration: Double = 30.0 // Buffer 30 seconds ahead
37
- static let preloadAssetKeys: [String] = [
38
- "playable", "duration", "tracks", "preferredTransform",
39
- ]
40
- static let gaplessPreloadCount: Int = 3 // Number of tracks to preload ahead
41
- }
42
-
43
- // MARK: - Properties
44
-
45
- private var player: AVQueuePlayer?
46
- private let playlistManager = PlaylistManager.shared
47
- private var mediaSessionManager: MediaSessionManager?
48
- private var currentPlaylistId: String?
49
- private var currentTrackIndex: Int = -1
50
- private var currentTracks: [TrackItem] = []
51
- // Debounce work item rapid playlist mutations (e.g. N individual removes
52
- // during shuffle) are coalesced into a single rebuildAVQueueFromCurrentPosition
53
- // call, preventing audio gaps/interruptions on iOS.
54
- private var pendingPlaylistUpdateWorkItem: DispatchWorkItem?
55
- private var isManuallySeeked = false
56
- private var currentRepeatMode: RepeatMode = .off
57
- private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
58
- private var boundaryTimeObserver: Any?
59
- private var currentItemObservers: [NSKeyValueObservation] = []
60
-
61
- // Gapless playback: Cache for preloaded assets
62
- private var preloadedAssets: [String: AVURLAsset] = [:]
63
- private let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
64
-
65
- // Debounce flag: prevents firing checkUpcomingTracksForUrls every boundary tick
66
- // once we've already requested URLs for the current track's remaining window.
67
- private var didRequestUrlsForCurrentItem = false
68
-
69
- // Temporary tracks for addToUpNext and playNext
70
- private var playNextStack: [TrackItem] = [] // LIFO - last added plays first
71
- private var upNextQueue: [TrackItem] = [] // FIFO - first added plays first
72
- private var currentTemporaryType: TemporaryType = .none
73
-
74
- // Enum to track what type of track is currently playing
75
- private enum TemporaryType {
76
- case none // Playing from original playlist
77
- case playNext // Currently in playNextStack
78
- case upNext // Currently in upNextQueue
79
- }
80
-
81
- // MARK: - Weak Callback Wrapper
82
-
83
- /// Wrapper to hold callbacks with weak reference for auto-cleanup
84
- private class WeakCallbackBox<T> {
85
- private(set) weak var owner: AnyObject?
86
- let callback: T
87
-
88
- init(owner: AnyObject, callback: T) {
89
- self.owner = owner
90
- self.callback = callback
91
- }
92
-
93
- var isAlive: Bool { owner != nil }
94
- }
95
-
96
- // Event callbacks - support multiple listeners with auto-cleanup
97
- private var onChangeTrackListeners: [WeakCallbackBox<(TrackItem, Reason?) -> Void>] = []
98
- private var onPlaybackStateChangeListeners:
99
- [WeakCallbackBox<(TrackPlayerState, Reason?) -> Void>] = []
100
- private var onSeekListeners: [WeakCallbackBox<(Double, Double) -> Void>] = []
101
- private var onPlaybackProgressChangeListeners:
102
- [WeakCallbackBox<(Double, Double, Bool?) -> Void>] = []
103
-
104
- // Thread-safe queue for listener access
105
- private let listenersQueue = DispatchQueue(
106
- label: "com.trackplayer.listeners", attributes: .concurrent)
107
-
25
+ static let preferredForwardBufferDuration: Double = 30.0
26
+ static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
27
+ static let gaplessPreloadCount: Int = 3
28
+ }
29
+
30
+ // MARK: - Thread infrastructure
31
+ internal let playerQueue = DispatchQueue(label: "com.nitroplayer.player", qos: .userInitiated)
32
+ internal let playerQueueKey = DispatchSpecificKey<Bool>()
33
+
34
+ // MARK: - Player
35
+ internal var player: AVQueuePlayer?
36
+ internal let playlistManager = PlaylistManager.shared
37
+ internal var mediaSessionManager: MediaSessionManager?
38
+
39
+ // MARK: - Playback state
40
+ internal var currentPlaylistId: String?
41
+ internal var currentTrackIndex: Int = -1
42
+ internal var currentTracks: [TrackItem] = []
43
+ internal var pendingPlaylistUpdateWorkItem: DispatchWorkItem?
44
+ internal var isManuallySeeked = false
45
+ internal var currentRepeatMode: RepeatMode = .off
46
+ internal var currentPlaybackSpeed: Double = 1.0
47
+ internal var lookaheadCount: Int = 5
48
+ internal var boundaryTimeObserver: Any?
49
+ internal var currentItemObservers: [NSKeyValueObservation] = []
50
+
51
+ // Gapless playback
52
+ internal var preloadedAssets: [String: AVURLAsset] = [:]
53
+ internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
54
+ internal var didRequestUrlsForCurrentItem = false
55
+
56
+ // MARK: - Temporary queue
57
+ internal var playNextStack: [TrackItem] = []
58
+ internal var upNextQueue: [TrackItem] = []
59
+ internal var currentTemporaryType: TemporaryType = .none
60
+
61
+ internal enum TemporaryType {
62
+ case none, playNext, upNext
63
+ }
64
+
65
+ // MARK: - Listener registries (v2 replaces WeakCallbackBox)
66
+ internal let onChangeTrackListeners = ListenerRegistry<(TrackItem, Reason?) -> Void>()
67
+ internal let onPlaybackStateChangeListeners = ListenerRegistry<(TrackPlayerState, Reason?) -> Void>()
68
+ internal let onSeekListeners = ListenerRegistry<(Double, Double) -> Void>()
69
+ internal let onProgressListeners = ListenerRegistry<(Double, Double, Bool?) -> Void>()
70
+ internal let onTracksNeedUpdateListeners = ListenerRegistry<([TrackItem], Int) -> Void>()
71
+ internal let onTemporaryQueueChangeListeners = ListenerRegistry<([TrackItem], [TrackItem]) -> Void>()
72
+
73
+ // MARK: - Singleton
108
74
  static let shared = TrackPlayerCore()
109
75
 
110
76
  // MARK: - Initialization
111
-
112
77
  private override init() {
113
78
  super.init()
79
+ playerQueue.setSpecific(key: playerQueueKey, value: true)
114
80
  setupAudioSession()
115
- setupPlayer()
116
- mediaSessionManager = MediaSessionManager()
117
- mediaSessionManager?.setTrackPlayerCore(self)
81
+ playerQueue.async { [weak self] in
82
+ self?.setupPlayer()
83
+ }
84
+ DispatchQueue.main.async { [weak self] in
85
+ self?.mediaSessionManager = MediaSessionManager()
86
+ self?.mediaSessionManager?.setTrackPlayerCore(self!)
87
+ }
118
88
  }
119
89
 
120
- // MARK: - Setup
121
-
122
- private func setupAudioSession() {
90
+ internal func setupAudioSession() {
123
91
  do {
124
92
  let audioSession = AVAudioSession.sharedInstance()
125
93
  try audioSession.setCategory(.playback, mode: .default, options: [])
@@ -129,2306 +97,112 @@ class TrackPlayerCore: NSObject {
129
97
  }
130
98
  }
131
99
 
132
- private func setupPlayer() {
133
- player = AVQueuePlayer()
134
-
135
- // MARK: - Gapless Playback Configuration
136
-
137
- // Start with stall-waiting enabled so the first track buffers before playing.
138
- // Once the first item is ready (readyToPlay), this is flipped to false for
139
- // gapless inter-track transitions (see setupCurrentItemObservers).
140
- player?.automaticallyWaitsToMinimizeStalling = true
141
-
142
- // Set playback rate to 1.0 immediately when ready (reduces gap between tracks)
143
- player?.actionAtItemEnd = .advance
144
-
145
- // Configure for high-quality audio playback with minimal latency
146
- if #available(iOS 15.0, *) {
147
- player?.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
148
- }
149
-
150
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 Gapless playback configured - automaticallyWaitsToMinimizeStalling=true (flipped to false on first readyToPlay)")
151
-
152
- // Listen for EQ enabled/disabled changes so we can update ALL items in
153
- // the queue atomically, keeping the audio pipeline configuration uniform.
154
- // A mismatch (some items with tap, some without) forces AVQueuePlayer to
155
- // reconfigure the pipeline at transition boundaries → audible gap.
156
- EqualizerCore.shared.addOnEnabledChangeListener(owner: self) { [weak self] enabled in
157
- guard let self = self, let player = self.player else { return }
158
- DispatchQueue.main.async {
159
- for item in player.items() {
160
- if enabled {
161
- EqualizerCore.shared.applyAudioMix(to: item)
162
- } else {
163
- item.audioMix = nil
164
- }
165
- }
166
- NitroPlayerLogger.log("TrackPlayerCore",
167
- "🎛️ EQ toggled \(enabled ? "ON" : "OFF") — updated \(player.items().count) items for pipeline consistency")
168
- }
169
- }
170
-
171
- setupPlayerObservers()
172
- }
173
-
174
- private func setupPlayerObservers() {
175
- guard let player = player else { return }
176
-
177
- // Observe player status
178
- player.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
179
- player.addObserver(self, forKeyPath: "rate", options: [.new], context: nil)
180
-
181
- // Observe time control status
182
- player.addObserver(self, forKeyPath: "timeControlStatus", options: [.new], context: nil)
183
-
184
- // Observe current item changes
185
- NotificationCenter.default.addObserver(
186
- self,
187
- selector: #selector(playerItemDidPlayToEndTime),
188
- name: .AVPlayerItemDidPlayToEndTime,
189
- object: nil
190
- )
191
-
192
- NotificationCenter.default.addObserver(
193
- self,
194
- selector: #selector(playerItemFailedToPlayToEndTime),
195
- name: .AVPlayerItemFailedToPlayToEndTime,
196
- object: nil
197
- )
198
-
199
- // Observe player item errors
200
- NotificationCenter.default.addObserver(
201
- self,
202
- selector: #selector(playerItemNewErrorLogEntry),
203
- name: .AVPlayerItemNewErrorLogEntry,
204
- object: nil
205
- )
206
-
207
- // Observe time jumps (seeks)
208
- NotificationCenter.default.addObserver(
209
- self,
210
- selector: #selector(playerItemTimeJumped),
211
- name: .AVPlayerItemTimeJumped,
212
- object: nil
213
- )
214
-
215
- // Observe when item changes (using KVO on currentItem)
216
- player.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
217
- }
218
-
219
- // MARK: - Boundary Time Observer
220
-
221
- private func setupBoundaryTimeObserver() {
222
- // Remove existing boundary observer if any
223
- if let existingObserver = boundaryTimeObserver, let currentPlayer = player {
224
- currentPlayer.removeTimeObserver(existingObserver)
225
- boundaryTimeObserver = nil
226
- }
227
-
228
- guard let player = player,
229
- let currentItem = player.currentItem
230
- else {
231
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Cannot setup boundary observer - no player or item")
232
- return
233
- }
234
-
235
- // Wait for duration to be available
236
- guard currentItem.status == .readyToPlay else {
237
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Item not ready, will setup boundaries when ready")
238
- return
239
- }
240
-
241
- let duration = currentItem.duration.seconds
242
- guard duration > 0 && !duration.isNaN && !duration.isInfinite else {
243
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Invalid duration: \(duration), cannot setup boundaries")
244
- return
245
- }
246
-
247
- // Determine interval based on duration
248
- let interval: Double
249
- if duration > Constants.twoHoursInSeconds {
250
- interval = Constants.boundaryIntervalLong
251
- } else if duration > Constants.oneHourInSeconds {
252
- interval = Constants.boundaryIntervalMedium
253
- } else {
254
- interval = Constants.boundaryIntervalDefault
255
- }
256
-
257
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Setting up periodic observer (interval: \(interval)s, duration: \(Int(duration))s)")
258
-
259
- let cmInterval = CMTime(seconds: interval, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
260
- boundaryTimeObserver = player.addPeriodicTimeObserver(forInterval: cmInterval, queue: .main) {
261
- [weak self] _ in
262
- self?.handleBoundaryTimeCrossed()
263
- }
264
-
265
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Periodic time observer setup complete")
266
- }
267
-
268
- private func handleBoundaryTimeCrossed() {
269
- guard let player = player,
270
- let currentItem = player.currentItem
271
- else { return }
272
-
273
- // Don't fire progress when paused
274
- guard player.rate > 0 else { return }
275
-
276
- let position = currentItem.currentTime().seconds
277
- let duration = currentItem.duration.seconds
278
-
279
- guard duration > 0 && !duration.isNaN && !duration.isInfinite else { return }
280
-
281
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Boundary crossed - position: \(Int(position))s / \(Int(duration))s, callback exists: \(!onPlaybackProgressChangeListeners.isEmpty)")
282
-
283
- notifyPlaybackProgress(
284
- position,
285
- duration,
286
- isManuallySeeked ? true : nil
287
- )
288
- isManuallySeeked = false
289
-
290
- // Proactive gapless URL resolution: when the track is within the
291
- // buffer window of its end, check if any upcoming tracks still
292
- // need URLs and fire the callback so JS can resolve them in time.
293
- let remaining = duration - position
294
- if remaining > 0 && remaining <= Constants.preferredForwardBufferDuration && !didRequestUrlsForCurrentItem {
295
- didRequestUrlsForCurrentItem = true
296
- NitroPlayerLogger.log("TrackPlayerCore",
297
- "⏳ \(Int(remaining))s remaining — proactively checking upcoming URLs")
298
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
299
- }
300
- }
301
-
302
- // MARK: - Notification Handlers
303
-
304
- @objc private func playerItemDidPlayToEndTime(notification: Notification) {
305
- NitroPlayerLogger.log("TrackPlayerCore", "\n🏁 Track finished playing")
306
-
307
- guard let finishedItem = notification.object as? AVPlayerItem else {
308
- return
309
- }
310
-
311
- // 1. TRACK repeat — handle FIRST, before any temp-track removal
312
- if currentRepeatMode == .track {
313
- NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
314
- player?.seek(to: .zero)
315
- player?.play()
316
- return // do not remove temp tracks, do not notify track change (same track looping)
317
- }
318
-
319
- // 2. Remove finished temp track from its list
320
- if let trackId = finishedItem.trackId {
321
- // Check if it was a playNext track
322
- if let index = playNextStack.firstIndex(where: { $0.id == trackId }) {
323
- let track = playNextStack.remove(at: index)
324
- NitroPlayerLogger.log("TrackPlayerCore", "🏁 Finished playNext track: \(track.title) - removed from stack")
325
- }
326
- // Check if it was an upNext track
327
- else if let index = upNextQueue.firstIndex(where: { $0.id == trackId }) {
328
- let track = upNextQueue.remove(at: index)
329
- NitroPlayerLogger.log("TrackPlayerCore", "🏁 Finished upNext track: \(track.title) - removed from queue")
330
- }
331
- // Otherwise it was from original playlist
332
- else if let track = currentTracks.first(where: { $0.id == trackId }) {
333
- NitroPlayerLogger.log("TrackPlayerCore", "🏁 Finished original track: \(track.title)")
334
- }
335
- }
336
-
337
- // 3. Normal next-track advance happens via actionAtItemEnd = .advance
338
- // The KVO observer (currentItemDidChange) will handle the track change notification
339
- if let player = player {
340
- NitroPlayerLogger.log("TrackPlayerCore", "📋 Remaining items in queue: \(player.items().count)")
341
- }
342
-
343
- // Check if upcoming tracks need URLs
344
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
345
- }
346
-
347
- @objc private func playerItemFailedToPlayToEndTime(notification: Notification) {
348
- if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
349
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Playback failed - \(error)")
350
- notifyPlaybackStateChange(.stopped, .error)
351
- }
352
- }
353
-
354
- @objc private func playerItemNewErrorLogEntry(notification: Notification) {
355
- guard let item = notification.object as? AVPlayerItem,
356
- let errorLog = item.errorLog()
357
- else { return }
358
-
359
- for event in errorLog.events ?? [] {
360
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)")
361
- }
362
-
363
- // Also check item error
364
- if let error = item.error {
365
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Item error - \(error.localizedDescription)")
366
- }
367
- }
368
-
369
- @objc private func playerItemTimeJumped(notification: Notification) {
370
- guard let player = player,
371
- let currentItem = player.currentItem
372
- else { return }
373
-
374
- let position = currentItem.currentTime().seconds
375
- let duration = currentItem.duration.seconds
376
-
377
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Time jumped (seek detected) - position: \(Int(position))s")
378
-
379
- // Call onSeek callback immediately
380
- notifySeek(position, duration)
381
-
382
- // Mark that this was a manual seek
383
- isManuallySeeked = true
384
-
385
- // Trigger immediate progress update
386
- handleBoundaryTimeCrossed()
387
- }
388
-
389
- // MARK: - KVO Observer
390
-
391
- override func observeValue(
392
- forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
393
- context: UnsafeMutableRawPointer?
394
- ) {
395
- guard let player = player else { return }
396
-
397
- NitroPlayerLogger.log("TrackPlayerCore", "👀 KVO - keyPath: \(keyPath ?? "nil")")
398
-
399
- if keyPath == "status" {
400
- NitroPlayerLogger.log("TrackPlayerCore", "👀 Player status changed to: \(player.status.rawValue)")
401
- if player.status == .readyToPlay {
402
- emitStateChange()
403
- } else if player.status == .failed {
404
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Player failed")
405
- notifyPlaybackStateChange(.stopped, .error)
406
- }
407
- } else if keyPath == "rate" {
408
- NitroPlayerLogger.log("TrackPlayerCore", "👀 Rate changed to: \(player.rate)")
409
- emitStateChange()
410
- } else if keyPath == "timeControlStatus" {
411
- NitroPlayerLogger.log("TrackPlayerCore", "👀 TimeControlStatus changed to: \(player.timeControlStatus.rawValue)")
412
- emitStateChange()
413
- } else if keyPath == "currentItem" {
414
- NitroPlayerLogger.log("TrackPlayerCore", "👀 Current item changed")
415
- currentItemDidChange()
416
- }
417
- }
418
-
419
- // MARK: - Item Change Handling
420
-
421
- @objc private func currentItemDidChange() {
422
- // Clear old item observers
423
- currentItemObservers.removeAll()
424
-
425
- // Reset proactive URL check debounce for the new track
426
- didRequestUrlsForCurrentItem = false
427
-
428
- // Track changed - update index
429
- guard let player = player,
430
- let currentItem = player.currentItem
431
- else {
432
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
433
- // Queue exhausted — handle PLAYLIST repeat
434
- if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = player {
435
- NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
436
- playNextStack.removeAll()
437
- upNextQueue.removeAll()
438
- currentTemporaryType = .none
439
-
440
- let allItems = currentTracks.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
441
- var lastItem: AVPlayerItem? = nil
442
- for item in allItems {
443
- player.insert(item, after: lastItem)
444
- lastItem = item
445
- }
446
- currentTrackIndex = 0
447
- player.play()
448
-
449
- if let firstTrack = currentTracks.first {
450
- notifyTrackChange(firstTrack, .repeat)
451
- mediaSessionManager?.onTrackChanged()
452
- }
453
- }
454
- return
455
- }
456
-
457
- #if DEBUG
458
- NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "▶", count: Constants.separatorLineLength))
459
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 CURRENT ITEM CHANGED")
460
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "▶", count: Constants.separatorLineLength))
461
-
462
- // Log current item details
463
- if let trackId = currentItem.trackId,
464
- let track = currentTracks.first(where: { $0.id == trackId })
465
- {
466
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ NOW PLAYING: \(track.title) - \(track.artist) (ID: \(track.id))")
467
- } else {
468
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ NOW PLAYING: Unknown track (trackId: \(currentItem.trackId ?? "nil"))")
469
- }
470
-
471
- // Show remaining items in queue
472
- let remainingItems = player.items()
473
- NitroPlayerLogger.log("TrackPlayerCore", "\n📋 REMAINING ITEMS IN QUEUE: \(remainingItems.count)")
474
- for (index, item) in remainingItems.enumerated() {
475
- if let trackId = item.trackId, let track = currentTracks.first(where: { $0.id == trackId }) {
476
- let marker = item == currentItem ? "▶️" : " "
477
- NitroPlayerLogger.log("TrackPlayerCore", "\(marker) [\(index + 1)] \(track.title) - \(track.artist)")
478
- } else {
479
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] ⚠️ Unknown track")
480
- }
481
- }
482
-
483
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "▶", count: Constants.separatorLineLength) + "\n")
484
- #endif
485
-
486
- // Log item status
487
- NitroPlayerLogger.log("TrackPlayerCore", "📱 Item status: \(currentItem.status.rawValue)")
488
-
489
- // Check for errors
490
- if let error = currentItem.error {
491
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Current item has error - \(error.localizedDescription)")
492
- }
493
-
494
- // Setup KVO observers for current item
495
- setupCurrentItemObservers(item: currentItem)
496
-
497
- // Update track index and determine temporary type
498
- if let trackId = currentItem.trackId {
499
- NitroPlayerLogger.log("TrackPlayerCore", "🔍 Looking up trackId '\(trackId)' in currentTracks...")
500
- NitroPlayerLogger.log("TrackPlayerCore", " Current index BEFORE lookup: \(currentTrackIndex)")
501
-
502
- // Update temporary type
503
- currentTemporaryType = determineCurrentTemporaryType()
504
- NitroPlayerLogger.log("TrackPlayerCore", " 🎯 Track type: \(currentTemporaryType)")
505
-
506
- // If it's a temporary track, don't update currentTrackIndex
507
- if currentTemporaryType != .none {
508
- // Find and emit the temporary track
509
- var tempTrack: TrackItem? = nil
510
- if currentTemporaryType == .playNext {
511
- tempTrack = playNextStack.first(where: { $0.id == trackId })
512
- } else if currentTemporaryType == .upNext {
513
- tempTrack = upNextQueue.first(where: { $0.id == trackId })
514
- }
515
-
516
- if let track = tempTrack {
517
- NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Temporary track: \(track.title) - \(track.artist)")
518
- NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack for temporary track")
519
- notifyTrackChange(track, .skip)
520
- mediaSessionManager?.onTrackChanged()
521
- }
522
- }
523
- // It's an original playlist track
524
- else if let index = currentTracks.firstIndex(where: { $0.id == trackId }) {
525
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found track at index: \(index)")
526
- NitroPlayerLogger.log("TrackPlayerCore", " Setting currentTrackIndex from \(currentTrackIndex) to \(index)")
527
-
528
- let oldIndex = currentTrackIndex
529
- currentTrackIndex = index
530
-
531
- if let track = currentTracks[safe: index] {
532
- NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Track: \(track.title) - \(track.artist)")
533
-
534
- // Only emit onChangeTrack if index actually changed
535
- // This prevents duplicate emissions
536
- if oldIndex != index {
537
- NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack (index changed from \(oldIndex) to \(index))")
538
- notifyTrackChange(track, .skip)
539
- mediaSessionManager?.onTrackChanged()
540
- } else {
541
- NitroPlayerLogger.log("TrackPlayerCore", " ⏭️ Skipping onChangeTrack emission (index unchanged)")
542
- }
543
- }
544
- } else {
545
- NitroPlayerLogger.log("TrackPlayerCore", " ⚠️ Track ID '\(trackId)' NOT FOUND in currentTracks!")
546
- #if DEBUG
547
- NitroPlayerLogger.log("TrackPlayerCore", " Current tracks:")
548
- for (idx, track) in currentTracks.enumerated() {
549
- NitroPlayerLogger.log("TrackPlayerCore", " [\(idx)] \(track.id) - \(track.title)")
550
- }
551
- #endif
552
- }
553
- }
554
-
555
- // Setup boundary observers when item is ready
556
- if currentItem.status == .readyToPlay {
557
- setupBoundaryTimeObserver()
558
- }
559
-
560
- // MARK: - Gapless Playback: Preload upcoming tracks when track changes
561
- // This ensures the next tracks are ready for seamless transitions
562
- preloadUpcomingTracks(from: currentTrackIndex + 1)
563
- cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
564
- }
565
-
566
- private func setupCurrentItemObservers(item: AVPlayerItem) {
567
- NitroPlayerLogger.log("TrackPlayerCore", "📱 Setting up item observers")
568
-
569
- // Observe status - recreate boundaries when ready and update now playing info
570
- let statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
571
- if item.status == .readyToPlay {
572
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Item ready, setting up boundaries")
573
- self?.setupBoundaryTimeObserver()
574
- // First item is buffered and ready — disable stall waiting for gapless inter-track transitions
575
- self?.player?.automaticallyWaitsToMinimizeStalling = false
576
- // Update now playing info now that duration is available
577
- self?.mediaSessionManager?.updateNowPlayingInfo()
578
- } else if item.status == .failed {
579
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Item failed")
580
- self?.notifyPlaybackStateChange(.stopped, .error)
581
- }
582
- }
583
- currentItemObservers.append(statusObserver)
584
-
585
- // Observe playback buffer
586
- let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { item, _ in
587
- if item.isPlaybackBufferEmpty {
588
- NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
589
- }
590
- }
591
- currentItemObservers.append(bufferEmptyObserver)
100
+ // MARK: - withPlayerQueue (async bridge to player thread)
592
101
 
593
- let bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) {
594
- item, _ in
595
- if item.isPlaybackLikelyToKeepUp {
596
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Buffer likely to keep up")
102
+ internal func withPlayerQueue<T>(_ block: @escaping () throws -> T) async throws -> T {
103
+ if DispatchQueue.getSpecific(key: playerQueueKey) == true { return try block() }
104
+ return try await withCheckedThrowingContinuation { cont in
105
+ playerQueue.async {
106
+ do { cont.resume(returning: try block()) }
107
+ catch { cont.resume(throwing: error) }
597
108
  }
598
109
  }
599
- currentItemObservers.append(bufferKeepUpObserver)
600
110
  }
601
111
 
602
- // MARK: - Playlist Management
603
-
604
- func loadPlaylist(playlistId: String) {
605
- if Thread.isMainThread {
606
- loadPlaylistInternal(playlistId: playlistId)
607
- } else {
608
- DispatchQueue.main.sync { [weak self] in
609
- self?.loadPlaylistInternal(playlistId: playlistId)
610
- }
112
+ @discardableResult
113
+ internal func withPlayerQueueNoThrow<T>(_ block: @escaping () -> T) async -> T {
114
+ if DispatchQueue.getSpecific(key: playerQueueKey) == true { return block() }
115
+ return await withCheckedContinuation { cont in
116
+ playerQueue.async { cont.resume(returning: block()) }
611
117
  }
612
118
  }
613
-
614
- func setPlaybackSpeed(_ speed: Double) {
615
- player?.rate = Float(speed)
616
- }
617
-
618
- func getPlaybackSpeed() -> Double {
619
- return Double(player?.rate ?? 1.0)
620
- }
621
-
622
- private func loadPlaylistInternal(playlistId: String) {
623
- NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
624
- NitroPlayerLogger.log("TrackPlayerCore", "📂 LOAD PLAYLIST REQUEST")
625
- NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
626
-
627
- // Clear temporary tracks when loading new playlist
628
- self.playNextStack.removeAll()
629
- self.upNextQueue.removeAll()
630
- self.currentTemporaryType = .none
631
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
632
-
633
- let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
634
- if let playlist = playlist {
635
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found playlist: \(playlist.name)")
636
- NitroPlayerLogger.log("TrackPlayerCore", " 📋 Contains \(playlist.tracks.count) tracks:")
637
- for (index, track) in playlist.tracks.enumerated() {
638
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
639
- }
640
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
641
119
 
642
- self.currentPlaylistId = playlistId
643
- self.updatePlayerQueue(tracks: playlist.tracks)
644
- // Emit initial state (paused/stopped before play)
645
- self.emitStateChange()
120
+ // MARK: - Listener add/remove (returns stable ID for cleanup)
646
121
 
647
- // Check if upcoming tracks need URLs
648
- self.checkUpcomingTracksForUrls(lookahead: lookaheadCount)
649
- } else {
650
- NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
651
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
652
- }
122
+ @discardableResult func addOnChangeTrackListener(_ cb: @escaping (TrackItem, Reason?) -> Void) -> Int64 {
123
+ onChangeTrackListeners.add(cb)
653
124
  }
654
-
655
- func updatePlaylist(playlistId: String) {
656
- guard currentPlaylistId == playlistId else { return }
657
-
658
- // Cancel any pending rebuild so back-to-back calls (e.g. N individual removes
659
- // during shuffle) collapse into a single rebuild at the end.
660
- pendingPlaylistUpdateWorkItem?.cancel()
661
-
662
- let workItem = DispatchWorkItem { [weak self] in
663
- guard let self = self else { return }
664
- guard self.currentPlaylistId == playlistId,
665
- let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
666
- else { return }
667
-
668
- // If nothing is playing yet, do a full load
669
- guard let player = self.player, player.currentItem != nil else {
670
- self.updatePlayerQueue(tracks: playlist.tracks)
671
- return
672
- }
673
-
674
- // Update tracks list without interrupting playback
675
- self.currentTracks = playlist.tracks
676
-
677
- // Rebuild only the items after the currently playing item
678
- self.rebuildAVQueueFromCurrentPosition()
679
- }
680
-
681
- pendingPlaylistUpdateWorkItem = workItem
682
- DispatchQueue.main.async(execute: workItem)
125
+ @discardableResult func removeOnChangeTrackListener(id: Int64) -> Bool {
126
+ onChangeTrackListeners.remove(id: id)
683
127
  }
684
128
 
685
- // MARK: - Public Methods
686
-
687
- func getCurrentPlaylistId() -> String? {
688
- return currentPlaylistId
129
+ @discardableResult func addOnPlaybackStateChangeListener(_ cb: @escaping (TrackPlayerState, Reason?) -> Void) -> Int64 {
130
+ onPlaybackStateChangeListeners.add(cb)
689
131
  }
690
-
691
- func getPlaylistManager() -> PlaylistManager {
692
- return playlistManager
132
+ @discardableResult func removeOnPlaybackStateChangeListener(id: Int64) -> Bool {
133
+ onPlaybackStateChangeListeners.remove(id: id)
693
134
  }
694
135
 
695
- private func emitStateChange(reason: Reason? = nil) {
696
- guard let player = player else { return }
697
-
698
- let state: TrackPlayerState
699
- if player.rate == 0 {
700
- state = .paused
701
- } else if player.timeControlStatus == .playing {
702
- state = .playing
703
- } else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
704
- state = .paused // Buffering
705
- } else {
706
- state = .stopped
707
- }
708
-
709
- NitroPlayerLogger.log("TrackPlayerCore", "🔔 Emitting state change: \(state)")
710
- NitroPlayerLogger.log("TrackPlayerCore", "🔔 Callback exists: \(!onPlaybackStateChangeListeners.isEmpty)")
711
- notifyPlaybackStateChange(state, reason)
712
- mediaSessionManager?.onPlaybackStateChanged()
136
+ @discardableResult func addOnSeekListener(_ cb: @escaping (Double, Double) -> Void) -> Int64 {
137
+ onSeekListeners.add(cb)
713
138
  }
714
-
715
- // MARK: - Gapless Playback Helpers
716
-
717
- /// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
718
- private func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false)
719
- -> AVPlayerItem?
720
- {
721
- // Get effective URL - uses local path if downloaded, otherwise remote URL
722
- let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
723
-
724
- // Create URL - use fileURLWithPath for local files, URL(string:) for remote
725
- let url: URL
726
- let isLocal = effectiveUrlString.hasPrefix("/")
727
-
728
- if isLocal {
729
- // Local file - use fileURLWithPath
730
- NitroPlayerLogger.log("TrackPlayerCore", "📥 Using DOWNLOADED version for \(track.title)")
731
- NitroPlayerLogger.log("TrackPlayerCore", " Local path: \(effectiveUrlString)")
732
-
733
- // Verify file exists
734
- if FileManager.default.fileExists(atPath: effectiveUrlString) {
735
- url = URL(fileURLWithPath: effectiveUrlString)
736
- NitroPlayerLogger.log("TrackPlayerCore", " File URL: \(url.absoluteString)")
737
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ File verified to exist")
738
- } else {
739
- NitroPlayerLogger.log("TrackPlayerCore", " ❌ Downloaded file does NOT exist at path!")
740
- NitroPlayerLogger.log("TrackPlayerCore", " Falling back to remote URL: \(track.url)")
741
- guard let remoteUrl = URL(string: track.url) else {
742
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Invalid remote URL: \(track.url)")
743
- return nil
744
- }
745
- url = remoteUrl
746
- }
747
- } else {
748
- // Remote URL
749
- guard let remoteUrl = URL(string: effectiveUrlString) else {
750
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Invalid URL for track: \(track.title) - \(effectiveUrlString)")
751
- return nil
752
- }
753
- url = remoteUrl
754
- NitroPlayerLogger.log("TrackPlayerCore", "🌐 Using REMOTE version for \(track.title)")
755
- }
756
-
757
- // Check if we have a preloaded asset for this track
758
- let asset: AVURLAsset
759
- if let preloadedAsset = preloadedAssets[track.id] {
760
- asset = preloadedAsset
761
- NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
762
- } else {
763
- asset = AVURLAsset(url: url, options: [
764
- AVURLAssetPreferPreciseDurationAndTimingKey: true
765
- ])
766
- }
767
-
768
- let item = AVPlayerItem(asset: asset)
769
-
770
- // Let the system choose the optimal forward buffer size (0 = automatic).
771
- // An explicit cap (e.g. 30 s) limits how much of the *next* queued item
772
- // AVQueuePlayer pre-rolls, which can cause audible gaps on HTTP streams.
773
- item.preferredForwardBufferDuration = 0
774
-
775
- // Enable automatic loading of item properties for faster starts
776
- item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
777
-
778
- // Store track ID for later reference
779
- item.trackId = track.id
780
-
781
- // If this is a preload request, start loading asset keys asynchronously.
782
- // EQ is applied INSIDE the completion handler so the "tracks" key is
783
- // already loaded → applyAudioMix takes the synchronous fast-path and
784
- // the tap is attached before AVQueuePlayer pre-rolls the item.
785
- if isPreload {
786
- asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
787
- // Asset keys are now loaded, which speeds up playback start
788
- var allKeysLoaded = true
789
- for key in Constants.preloadAssetKeys {
790
- var error: NSError?
791
- let status = asset.statusOfValue(forKey: key, error: &error)
792
- if status == .failed {
793
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")")
794
- allKeysLoaded = false
795
- }
796
- }
797
- if allKeysLoaded {
798
- NitroPlayerLogger.log("TrackPlayerCore", "✅ All asset keys preloaded for \(track.title)")
799
- }
800
- // "tracks" key is now loaded — EQ tap attaches synchronously
801
- EqualizerCore.shared.applyAudioMix(to: item)
802
- }
803
- } else {
804
- // Non-preload: asset may already have keys loaded (preloadedAssets cache)
805
- // so applyAudioMix will use the sync path if possible, async otherwise.
806
- EqualizerCore.shared.applyAudioMix(to: item)
807
- }
808
-
809
- return item
139
+ @discardableResult func removeOnSeekListener(id: Int64) -> Bool {
140
+ onSeekListeners.remove(id: id)
810
141
  }
811
142
 
812
- /// Preloads assets for upcoming tracks to enable gapless playback
813
- private func preloadUpcomingTracks(from startIndex: Int) {
814
- // Capture the set of track IDs that already have AVPlayerItems in the
815
- // queue (main-thread access). Creating duplicate AVURLAssets for these
816
- // would start parallel HTTP downloads for the same URLs, competing
817
- // with AVQueuePlayer's own pre-roll buffering and potentially starving
818
- // the next-item buffer — resulting in an audible gap at the transition.
819
- let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
820
-
821
- preloadQueue.async { [weak self] in
822
- guard let self = self else { return }
823
-
824
- // Capture currentTracks to avoid race condition with main thread
825
- let tracks = self.currentTracks
826
- let endIndex = min(startIndex + Constants.gaplessPreloadCount, tracks.count)
827
-
828
- for i in startIndex..<endIndex {
829
- guard i < tracks.count else { break }
830
- let track = tracks[i]
831
-
832
- // Skip if already preloaded OR already in the player queue
833
- if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
834
- continue
835
- }
836
-
837
- // Use effective URL so downloaded tracks preload from disk, not network
838
- let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
839
- let isLocal = effectiveUrlString.hasPrefix("/")
840
-
841
- let url: URL
842
- if isLocal {
843
- url = URL(fileURLWithPath: effectiveUrlString)
844
- } else {
845
- guard let remoteUrl = URL(string: effectiveUrlString) else { continue }
846
- url = remoteUrl
847
- }
848
-
849
- let asset = AVURLAsset(url: url, options: [
850
- AVURLAssetPreferPreciseDurationAndTimingKey: true
851
- ])
852
-
853
- // Preload essential keys for gapless playback
854
- asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
855
- var allKeysLoaded = true
856
- for key in Constants.preloadAssetKeys {
857
- var error: NSError?
858
- let status = asset.statusOfValue(forKey: key, error: &error)
859
- if status != .loaded {
860
- allKeysLoaded = false
861
- break
862
- }
863
- }
864
-
865
- if allKeysLoaded {
866
- DispatchQueue.main.async {
867
- self?.preloadedAssets[track.id] = asset
868
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Preloaded asset for upcoming track: \(track.title)")
869
- }
870
- }
871
- }
872
- }
873
- }
143
+ @discardableResult func addOnProgressListener(_ cb: @escaping (Double, Double, Bool?) -> Void) -> Int64 {
144
+ onProgressListeners.add(cb)
874
145
  }
875
-
876
- /// Clears preloaded assets that are no longer needed
877
- private func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
878
- // Must run on main thread — preloadedAssets is only mutated on main
879
- DispatchQueue.main.async { [weak self] in
880
- guard let self = self else { return }
881
-
882
- // Keep assets for current track and upcoming tracks within preload range
883
- let keepRange =
884
- currentIndex..<min(
885
- currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
886
- let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
887
-
888
- let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
889
- for id in assetsToRemove {
890
- self.preloadedAssets.removeValue(forKey: id)
891
- }
892
-
893
- if !assetsToRemove.isEmpty {
894
- NitroPlayerLogger.log("TrackPlayerCore", "🧹 Cleaned up \(assetsToRemove.count) preloaded assets")
895
- }
896
- }
146
+ @discardableResult func removeOnProgressListener(id: Int64) -> Bool {
147
+ onProgressListeners.remove(id: id)
897
148
  }
898
149
 
899
- // MARK: - Listener Registration
900
-
901
- func addOnChangeTrackListener(
902
- owner: AnyObject, _ listener: @escaping (TrackItem, Reason?) -> Void
903
- ) {
904
- let box = WeakCallbackBox(owner: owner, callback: listener)
905
- listenersQueue.async(flags: .barrier) { [weak self] in
906
- self?.onChangeTrackListeners.append(box)
907
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Added onChangeTrack listener (total: \(self?.onChangeTrackListeners.count ?? 0))")
908
- }
150
+ @discardableResult func addOnTracksNeedUpdateListener(_ cb: @escaping ([TrackItem], Int) -> Void) -> Int64 {
151
+ onTracksNeedUpdateListeners.add(cb)
909
152
  }
910
-
911
- func addOnPlaybackStateChangeListener(
912
- owner: AnyObject,
913
- _ listener: @escaping (TrackPlayerState, Reason?) -> Void
914
- ) {
915
- let box = WeakCallbackBox(owner: owner, callback: listener)
916
- listenersQueue.async(flags: .barrier) { [weak self] in
917
- self?.onPlaybackStateChangeListeners.append(box)
918
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Added onPlaybackStateChange listener (total: \(self?.onPlaybackStateChangeListeners.count ?? 0))")
919
- }
153
+ @discardableResult func removeOnTracksNeedUpdateListener(id: Int64) -> Bool {
154
+ onTracksNeedUpdateListeners.remove(id: id)
920
155
  }
921
156
 
922
- func addOnSeekListener(owner: AnyObject, _ listener: @escaping (Double, Double) -> Void) {
923
- let box = WeakCallbackBox(owner: owner, callback: listener)
924
- listenersQueue.async(flags: .barrier) { [weak self] in
925
- self?.onSeekListeners.append(box)
926
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Added onSeek listener (total: \(self?.onSeekListeners.count ?? 0))")
927
- }
157
+ @discardableResult func addOnTemporaryQueueChangeListener(_ cb: @escaping ([TrackItem], [TrackItem]) -> Void) -> Int64 {
158
+ onTemporaryQueueChangeListeners.add(cb)
928
159
  }
929
-
930
- func addOnPlaybackProgressChangeListener(
931
- owner: AnyObject,
932
- _ listener: @escaping (Double, Double, Bool?) -> Void
933
- ) {
934
- let box = WeakCallbackBox(owner: owner, callback: listener)
935
- listenersQueue.async(flags: .barrier) { [weak self] in
936
- self?.onPlaybackProgressChangeListeners.append(box)
937
- NitroPlayerLogger.log("TrackPlayerCore", "🎯 Added onPlaybackProgressChange listener (total: \(self?.onPlaybackProgressChangeListeners.count ?? 0))")
938
- }
160
+ @discardableResult func removeOnTemporaryQueueChangeListener(id: Int64) -> Bool {
161
+ onTemporaryQueueChangeListeners.remove(id: id)
939
162
  }
940
163
 
941
- // MARK: - Listener Notification Helpers
942
-
943
- private func notifyTrackChange(_ track: TrackItem, _ reason: Reason?) {
944
- listenersQueue.async(flags: .barrier) { [weak self] in
945
- guard let self = self else { return }
164
+ // MARK: - Simple accessors
165
+ func getCurrentPlaylistId() -> String? { currentPlaylistId }
166
+ func getPlaylistManager() -> PlaylistManager { playlistManager }
167
+ func isAndroidAutoConnected() -> Bool { false } // iOS stub
168
+ func getRepeatMode() -> RepeatMode { currentRepeatMode }
946
169
 
947
- // Remove dead listeners
948
- self.onChangeTrackListeners.removeAll { !$0.isAlive }
949
-
950
- // Get live callbacks (all remaining are alive after removeAll)
951
- let liveCallbacks = self.onChangeTrackListeners.map { $0.callback }
952
-
953
- // Call on main thread
954
- if !liveCallbacks.isEmpty {
955
- DispatchQueue.main.async {
956
- for callback in liveCallbacks {
957
- callback(track, reason)
958
- }
959
- }
170
+ // MARK: - Lifecycle
171
+ func destroy() {
172
+ playerQueue.async { [weak self] in
173
+ guard let self else { return }
174
+ if let obs = self.boundaryTimeObserver, let p = self.player {
175
+ p.removeTimeObserver(obs)
960
176
  }
961
- }
962
- }
963
-
964
- private func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
965
- listenersQueue.async(flags: .barrier) { [weak self] in
966
- guard let self = self else { return }
967
-
968
- self.onPlaybackStateChangeListeners.removeAll { !$0.isAlive }
969
-
970
- let liveCallbacks = self.onPlaybackStateChangeListeners.map { $0.callback }
971
-
972
- if !liveCallbacks.isEmpty {
973
- DispatchQueue.main.async {
974
- for callback in liveCallbacks {
975
- callback(state, reason)
976
- }
977
- }
177
+ self.currentItemObservers.removeAll()
178
+ if let p = self.player {
179
+ p.removeObserver(self, forKeyPath: "status")
180
+ p.removeObserver(self, forKeyPath: "rate")
181
+ p.removeObserver(self, forKeyPath: "timeControlStatus")
182
+ p.removeObserver(self, forKeyPath: "currentItem")
978
183
  }
184
+ NotificationCenter.default.removeObserver(self)
185
+ self.preloadedAssets.removeAll()
979
186
  }
980
187
  }
981
188
 
982
- private func notifySeek(_ position: Double, _ duration: Double) {
983
- listenersQueue.async(flags: .barrier) { [weak self] in
984
- guard let self = self else { return }
985
-
986
- self.onSeekListeners.removeAll { !$0.isAlive }
987
-
988
- let liveCallbacks = self.onSeekListeners.map { $0.callback }
989
-
990
- if !liveCallbacks.isEmpty {
991
- DispatchQueue.main.async {
992
- for callback in liveCallbacks {
993
- callback(position, duration)
994
- }
995
- }
996
- }
997
- }
189
+ deinit {
190
+ NitroPlayerLogger.log("TrackPlayerCore", "🧹 deinit")
998
191
  }
192
+ }
999
193
 
1000
- private func notifyPlaybackProgress(_ position: Double, _ duration: Double, _ isPlaying: Bool?) {
1001
- listenersQueue.async(flags: .barrier) { [weak self] in
1002
- guard let self = self else { return }
1003
-
1004
- self.onPlaybackProgressChangeListeners.removeAll { !$0.isAlive }
1005
-
1006
- let liveCallbacks = self.onPlaybackProgressChangeListeners.map { $0.callback }
1007
-
1008
- if !liveCallbacks.isEmpty {
1009
- DispatchQueue.main.async {
1010
- for callback in liveCallbacks {
1011
- callback(position, duration, isPlaying)
1012
- }
1013
- }
1014
- }
1015
- }
194
+ // Safe array subscript
195
+ extension Array {
196
+ subscript(safe index: Int) -> Element? {
197
+ indices.contains(index) ? self[index] : nil
1016
198
  }
199
+ }
1017
200
 
1018
- // MARK: - State Management
1019
-
1020
- // MARK: - Queue Management
1021
-
1022
- private func updatePlayerQueue(tracks: [TrackItem]) {
1023
- NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "=", count: Constants.separatorLineLength))
1024
- NitroPlayerLogger.log("TrackPlayerCore", "📋 UPDATE PLAYER QUEUE - Received \(tracks.count) tracks")
1025
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength))
1026
-
1027
- #if DEBUG
1028
- for (index, track) in tracks.enumerated() {
1029
- let isDownloaded = DownloadManagerCore.shared.isTrackDownloaded(trackId: track.id)
1030
- let downloadStatus = isDownloaded ? "📥 DOWNLOADED" : "🌐 REMOTE"
1031
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] 🎵 \(track.title) - \(track.artist) (ID: \(track.id)) - \(downloadStatus)")
1032
- if isDownloaded {
1033
- if let localPath = DownloadManagerCore.shared.getLocalPath(trackId: track.id) {
1034
- NitroPlayerLogger.log("TrackPlayerCore", " Local path: \(localPath)")
1035
- }
1036
- }
1037
- }
1038
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength) + "\n")
1039
- #endif
1040
-
1041
- // Store tracks for index tracking
1042
- currentTracks = tracks
1043
- currentTrackIndex = 0
1044
- NitroPlayerLogger.log("TrackPlayerCore", "🔢 Reset currentTrackIndex to 0 (will be updated by KVO observer)")
1045
-
1046
- // Remove old boundary observer if exists (this is safe)
1047
- if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
1048
- currentPlayer.removeTimeObserver(boundaryObserver)
1049
- boundaryTimeObserver = nil
1050
- }
1051
-
1052
- // Re-enable stall waiting for the new first track so it buffers before playing.
1053
- // Will be flipped back to false once the first item reaches readyToPlay.
1054
- player?.automaticallyWaitsToMinimizeStalling = true
1055
-
1056
- // Clear old preloaded assets when loading new queue
1057
- preloadedAssets.removeAll()
1058
-
1059
- // Replace current queue (player should always exist after setupPlayer)
1060
- guard let existingPlayer = self.player else {
1061
- NitroPlayerLogger.log("TrackPlayerCore", "❌ No player available")
1062
- return
1063
- }
1064
-
1065
- // Always clear old items so a stale playlist doesn't keep playing.
1066
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
1067
- existingPlayer.removeAllItems()
1068
-
1069
- // Lazy-load mode: if any track has no URL AND is not downloaded locally,
1070
- // we can't create an AVPlayerItem for it and the queue order would be wrong.
1071
- // Downloaded tracks with empty remote URLs still play from disk via getEffectiveUrl.
1072
- let isLazyLoad = tracks.contains {
1073
- $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
1074
- }
1075
- if isLazyLoad {
1076
- NitroPlayerLogger.log("TrackPlayerCore", "⏳ Lazy-load mode — player cleared, awaiting URL resolution")
1077
- return
1078
- }
1079
-
1080
- // Create gapless-optimized AVPlayerItems from tracks
1081
- let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
1082
- let isPreload = index < Constants.gaplessPreloadCount
1083
- return createGaplessPlayerItem(for: track, isPreload: isPreload)
1084
- }
1085
-
1086
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 Created \(items.count) gapless-optimized player items")
1087
-
1088
- guard !items.isEmpty else {
1089
- NitroPlayerLogger.log("TrackPlayerCore", "❌ No valid items to play")
1090
- return
1091
- }
1092
-
1093
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Adding \(items.count) new items to player")
1094
-
1095
- // Add new items IN ORDER
1096
- // IMPORTANT: insert(after: nil) puts item at the start
1097
- // To maintain order, we need to track the last inserted item
1098
- var lastItem: AVPlayerItem? = nil
1099
- for (index, item) in items.enumerated() {
1100
- existingPlayer.insert(item, after: lastItem)
1101
- lastItem = item
1102
-
1103
- #if DEBUG
1104
- if let trackId = item.trackId, let track = tracks.first(where: { $0.id == trackId }) {
1105
- NitroPlayerLogger.log("TrackPlayerCore", " ➕ Added to player queue [\(index + 1)]: \(track.title)")
1106
- }
1107
- #endif
1108
- }
1109
-
1110
- #if DEBUG
1111
- let trackById = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
1112
- NitroPlayerLogger.log("TrackPlayerCore", "\n🔍 VERIFICATION - Player now has \(existingPlayer.items().count) items:")
1113
- for (index, item) in existingPlayer.items().enumerated() {
1114
- if let trackId = item.trackId, let track = trackById[trackId] {
1115
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] ✓ \(track.title) - \(track.artist) (ID: \(track.id))")
1116
- } else {
1117
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] ⚠️ Unknown item (no trackId)")
1118
- }
1119
- }
1120
- if let currentItem = existingPlayer.currentItem,
1121
- let trackId = currentItem.trackId,
1122
- let track = trackById[trackId]
1123
- {
1124
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Current item: \(track.title)")
1125
- }
1126
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength) + "\n")
1127
- #endif
1128
-
1129
- // Note: Boundary time observers will be set up automatically when item becomes ready
1130
- // This happens in setupCurrentItemObservers() -> status observer -> setupBoundaryTimeObserver()
1131
-
1132
- // Notify track change
1133
- if let firstTrack = tracks.first {
1134
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 Emitting track change: \(firstTrack.title)")
1135
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 onChangeTrack callbacks count: \(onChangeTrackListeners.count)")
1136
- notifyTrackChange(firstTrack, nil)
1137
- mediaSessionManager?.onTrackChanged()
1138
- }
1139
-
1140
- // Start preloading upcoming tracks for gapless playback
1141
- preloadUpcomingTracks(from: 1)
1142
-
1143
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Queue updated with \(items.count) gapless-optimized tracks")
1144
- }
1145
-
1146
- func getCurrentTrack() -> TrackItem? {
1147
- // If playing a temporary track, return that
1148
- if currentTemporaryType != .none,
1149
- let currentItem = player?.currentItem,
1150
- let trackId = currentItem.trackId
1151
- {
1152
- if currentTemporaryType == .playNext {
1153
- return playNextStack.first(where: { $0.id == trackId })
1154
- } else if currentTemporaryType == .upNext {
1155
- return upNextQueue.first(where: { $0.id == trackId })
1156
- }
1157
- }
1158
-
1159
- // Otherwise return from original playlist
1160
- guard currentTrackIndex >= 0 && currentTrackIndex < currentTracks.count else {
1161
- return nil
1162
- }
1163
- return currentTracks[currentTrackIndex]
1164
- }
1165
-
1166
- func getActualQueue() -> [TrackItem] {
1167
- // Called from Promise.async background thread
1168
- // Schedule on main thread and wait for result
1169
- if Thread.isMainThread {
1170
- return getActualQueueInternal()
1171
- } else {
1172
- var queue: [TrackItem] = []
1173
- DispatchQueue.main.sync { [weak self] in
1174
- queue = self?.getActualQueueInternal() ?? []
1175
- }
1176
- return queue
1177
- }
1178
- }
1179
-
1180
- private func getActualQueueInternal() -> [TrackItem] {
1181
- var queue: [TrackItem] = []
1182
- queue.reserveCapacity(currentTracks.count + playNextStack.count + upNextQueue.count)
1183
-
1184
- // Add tracks before current (original playlist)
1185
- // When a temp track is playing, include the original track at currentTrackIndex
1186
- // (it already played before the temp track started)
1187
- let beforeEnd = currentTemporaryType != .none
1188
- ? min(currentTrackIndex + 1, currentTracks.count) : currentTrackIndex
1189
- if beforeEnd > 0 {
1190
- queue.append(contentsOf: currentTracks[0..<beforeEnd])
1191
- }
1192
-
1193
- // Add current track (temp or original)
1194
- if let current = getCurrentTrack() {
1195
- queue.append(current)
1196
- }
1197
-
1198
- // Add playNext stack (LIFO - most recently added plays first)
1199
- // Skip index 0 if current track is from playNext (it's already added as current)
1200
- if currentTemporaryType == .playNext && playNextStack.count > 1 {
1201
- queue.append(contentsOf: playNextStack.dropFirst())
1202
- } else if currentTemporaryType != .playNext {
1203
- queue.append(contentsOf: playNextStack)
1204
- }
1205
-
1206
- // Add upNext queue (in order, FIFO)
1207
- // Skip index 0 if current track is from upNext (it's already added as current)
1208
- if currentTemporaryType == .upNext && upNextQueue.count > 1 {
1209
- queue.append(contentsOf: upNextQueue.dropFirst())
1210
- } else if currentTemporaryType != .upNext {
1211
- queue.append(contentsOf: upNextQueue)
1212
- }
1213
-
1214
- // Add remaining original tracks
1215
- if currentTrackIndex + 1 < currentTracks.count {
1216
- queue.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
1217
- }
1218
-
1219
- return queue
1220
- }
1221
-
1222
- func play() {
1223
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ play() called")
1224
- if Thread.isMainThread {
1225
- playInternal()
1226
- } else {
1227
- DispatchQueue.main.sync { [weak self] in
1228
- self?.playInternal()
1229
- }
1230
- }
1231
- }
1232
-
1233
- private func playInternal() {
1234
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Calling player.play()")
1235
- if let player = self.player {
1236
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Player status: \(player.status.rawValue)")
1237
- if let currentItem = player.currentItem {
1238
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Current item status: \(currentItem.status.rawValue)")
1239
- if let error = currentItem.error {
1240
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Current item error: \(error.localizedDescription)")
1241
- }
1242
- }
1243
- player.play()
1244
- // Emit state change immediately for responsive UI
1245
- // KVO will also fire, but this ensures immediate feedback
1246
- DispatchQueue.main.asyncAfter(deadline: .now() + Constants.stateChangeDelay) {
1247
- [weak self] in
1248
- self?.emitStateChange()
1249
- }
1250
- } else {
1251
- NitroPlayerLogger.log("TrackPlayerCore", "❌ No player available")
1252
- }
1253
- }
1254
-
1255
- func pause() {
1256
- NitroPlayerLogger.log("TrackPlayerCore", "⏸️ pause() called")
1257
- if Thread.isMainThread {
1258
- pauseInternal()
1259
- } else {
1260
- DispatchQueue.main.sync { [weak self] in
1261
- self?.pauseInternal()
1262
- }
1263
- }
1264
- }
1265
-
1266
- private func pauseInternal() {
1267
- self.player?.pause()
1268
- // Emit state change immediately for responsive UI
1269
- DispatchQueue.main.asyncAfter(deadline: .now() + Constants.stateChangeDelay) { [weak self] in
1270
- self?.emitStateChange()
1271
- }
1272
- }
1273
-
1274
- func playSong(songId: String, fromPlaylist: String?) {
1275
- DispatchQueue.main.async { [weak self] in
1276
- self?.playSongInternal(songId: songId, fromPlaylist: fromPlaylist)
1277
- }
1278
- }
1279
-
1280
- private func playSongInternal(songId: String, fromPlaylist: String?) {
1281
- // Clear temporary tracks when directly playing a song
1282
- self.playNextStack.removeAll()
1283
- self.upNextQueue.removeAll()
1284
- self.currentTemporaryType = .none
1285
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
1286
-
1287
- var targetPlaylistId: String?
1288
- var songIndex: Int = -1
1289
-
1290
- // Case 1: If fromPlaylist is provided, use that playlist
1291
- if let playlistId = fromPlaylist {
1292
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 Looking for song in specified playlist: \(playlistId)")
1293
- if let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) {
1294
- if let index = playlist.tracks.firstIndex(where: { $0.id == songId }) {
1295
- targetPlaylistId = playlistId
1296
- songIndex = index
1297
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Found song at index \(index) in playlist \(playlistId)")
1298
- } else {
1299
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Song \(songId) not found in specified playlist \(playlistId)")
1300
- return
1301
- }
1302
- } else {
1303
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Playlist \(playlistId) not found")
1304
- return
1305
- }
1306
- }
1307
- // Case 2: If fromPlaylist is not provided, search in current/loaded playlist first
1308
- else {
1309
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 No playlist specified, checking current playlist")
1310
-
1311
- // Check if song exists in currently loaded playlist
1312
- if let currentId = self.currentPlaylistId,
1313
- let currentPlaylist = self.playlistManager.getPlaylist(playlistId: currentId)
1314
- {
1315
- if let index = currentPlaylist.tracks.firstIndex(where: { $0.id == songId }) {
1316
- targetPlaylistId = currentId
1317
- songIndex = index
1318
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Found song at index \(index) in current playlist \(currentId)")
1319
- }
1320
- }
1321
-
1322
- // If not found in current playlist, search in all playlists
1323
- if songIndex == -1 {
1324
- NitroPlayerLogger.log("TrackPlayerCore", "🔍 Song not found in current playlist, searching all playlists...")
1325
- let allPlaylists = self.playlistManager.getAllPlaylists()
1326
-
1327
- for playlist in allPlaylists {
1328
- if let index = playlist.tracks.firstIndex(where: { $0.id == songId }) {
1329
- targetPlaylistId = playlist.id
1330
- songIndex = index
1331
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Found song at index \(index) in playlist \(playlist.id)")
1332
- break
1333
- }
1334
- }
1335
-
1336
- // If still not found, just use the first playlist if available
1337
- if songIndex == -1 && !allPlaylists.isEmpty {
1338
- targetPlaylistId = allPlaylists[0].id
1339
- songIndex = 0
1340
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Song not found in any playlist, using first playlist and starting at index 0")
1341
- }
1342
- }
1343
- }
1344
-
1345
- // Now play the song
1346
- guard let playlistId = targetPlaylistId, songIndex >= 0 else {
1347
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Could not determine playlist or song index")
1348
- return
1349
- }
1350
-
1351
- // Load playlist if it's different from current
1352
- if self.currentPlaylistId != playlistId {
1353
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Loading new playlist: \(playlistId)")
1354
- if let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) {
1355
- self.currentPlaylistId = playlistId
1356
- self.updatePlayerQueue(tracks: playlist.tracks)
1357
- }
1358
- }
1359
-
1360
- // Play from the found index
1361
- NitroPlayerLogger.log("TrackPlayerCore", "▶️ Playing from index: \(songIndex)")
1362
- self.playFromIndex(index: songIndex)
1363
- }
1364
-
1365
- func skipToNext() {
1366
- if Thread.isMainThread {
1367
- skipToNextInternal()
1368
- } else {
1369
- DispatchQueue.main.sync { [weak self] in
1370
- self?.skipToNextInternal()
1371
- }
1372
- }
1373
- }
1374
-
1375
- private func skipToNextInternal() {
1376
- guard let queuePlayer = self.player else { return }
1377
-
1378
- // Lazy-load: AVQueuePlayer is empty because updatePlayerQueue deferred population.
1379
- // Delegate to playFromIndexInternal which handles both the has-URL (rebuild queue)
1380
- // and no-URL (defer + emit) cases correctly.
1381
- if queuePlayer.items().isEmpty && !currentTracks.isEmpty {
1382
- let nextIndex = currentTrackIndex + 1
1383
- if nextIndex < currentTracks.count {
1384
- _ = skipToIndexInternal(index: nextIndex)
1385
- }
1386
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1387
- return
1388
- }
1389
-
1390
- // Remove current temp track from its list before advancing
1391
- if let trackId = queuePlayer.currentItem?.trackId {
1392
- if currentTemporaryType == .playNext {
1393
- if let idx = playNextStack.firstIndex(where: { $0.id == trackId }) {
1394
- playNextStack.remove(at: idx)
1395
- }
1396
- } else if currentTemporaryType == .upNext {
1397
- if let idx = upNextQueue.firstIndex(where: { $0.id == trackId }) {
1398
- upNextQueue.remove(at: idx)
1399
- }
1400
- }
1401
- }
1402
-
1403
- // Check if there are more items in the player queue
1404
- if queuePlayer.items().count > 1 {
1405
- queuePlayer.advanceToNextItem()
1406
- } else {
1407
- queuePlayer.pause()
1408
- self.notifyPlaybackStateChange(.stopped, .end)
1409
- }
1410
-
1411
- // Check if upcoming tracks need URLs
1412
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1413
- }
1414
-
1415
- func skipToPrevious() {
1416
- if Thread.isMainThread {
1417
- skipToPreviousInternal()
1418
- } else {
1419
- DispatchQueue.main.sync { [weak self] in
1420
- self?.skipToPreviousInternal()
1421
- }
1422
- }
1423
- }
1424
-
1425
- private func skipToPreviousInternal() {
1426
- guard let queuePlayer = self.player else { return }
1427
-
1428
- let currentTime = queuePlayer.currentTime()
1429
- if currentTime.seconds > Constants.skipToPreviousThreshold {
1430
- // If more than threshold seconds in, restart current track
1431
- queuePlayer.seek(to: .zero)
1432
- } else if self.currentTemporaryType != .none {
1433
- // Playing temporary track — remove from its list, then restart
1434
- if let trackId = queuePlayer.currentItem?.trackId {
1435
- if currentTemporaryType == .playNext {
1436
- if let idx = playNextStack.firstIndex(where: { $0.id == trackId }) {
1437
- playNextStack.remove(at: idx)
1438
- }
1439
- } else if currentTemporaryType == .upNext {
1440
- if let idx = upNextQueue.firstIndex(where: { $0.id == trackId }) {
1441
- upNextQueue.remove(at: idx)
1442
- }
1443
- }
1444
- }
1445
- // Go to current original track position (skip back from temp)
1446
- self.rebuildQueueFromPlaylistIndex(index: self.currentTrackIndex)
1447
- } else if self.currentTrackIndex > 0 {
1448
- // Go to previous track in original playlist
1449
- let previousIndex = self.currentTrackIndex - 1
1450
- self.rebuildQueueFromPlaylistIndex(index: previousIndex)
1451
- } else {
1452
- // Already at first track, restart it
1453
- queuePlayer.seek(to: .zero)
1454
- }
1455
-
1456
- // Check if upcoming tracks need URLs
1457
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1458
- }
1459
-
1460
- func seek(position: Double) {
1461
- if Thread.isMainThread {
1462
- seekInternal(position: position)
1463
- } else {
1464
- DispatchQueue.main.sync { [weak self] in
1465
- self?.seekInternal(position: position)
1466
- }
1467
- }
1468
- }
1469
-
1470
- private func seekInternal(position: Double) {
1471
- guard let player = self.player else { return }
1472
-
1473
- self.isManuallySeeked = true
1474
- let time = CMTime(seconds: position, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
1475
- player.seek(to: time) { [weak self] completed in
1476
- // Always update now playing info to restore playback rate after seek
1477
- // This ensures the scrubber animation resumes correctly
1478
- self?.mediaSessionManager?.updateNowPlayingInfo()
1479
-
1480
- if completed {
1481
- let duration = player.currentItem?.duration.seconds ?? 0.0
1482
- self?.notifySeek(position, duration)
1483
- }
1484
- }
1485
- }
1486
-
1487
- // MARK: - Repeat Mode
1488
-
1489
- func setRepeatMode(mode: RepeatMode) -> Bool {
1490
- currentRepeatMode = mode
1491
- DispatchQueue.main.async { [weak self] in
1492
- self?.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
1493
- }
1494
- NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
1495
- return true
1496
- }
1497
-
1498
- func getRepeatMode() -> RepeatMode {
1499
- return currentRepeatMode
1500
- }
1501
-
1502
- func getState() -> PlayerState {
1503
- // Called from Promise.async background thread
1504
- // Schedule on main thread and wait for result
1505
- if Thread.isMainThread {
1506
- return getStateInternal()
1507
- } else {
1508
- var state: PlayerState!
1509
- DispatchQueue.main.sync { [weak self] in
1510
- state =
1511
- self?.getStateInternal()
1512
- ?? PlayerState(
1513
- currentTrack: nil,
1514
- currentPosition: 0.0,
1515
- totalDuration: 0.0,
1516
- currentState: .stopped,
1517
- currentPlaylistId: nil,
1518
- currentIndex: -1.0,
1519
- currentPlayingType: .notPlaying
1520
- )
1521
- }
1522
- return state
1523
- }
1524
- }
1525
-
1526
- private func getStateInternal() -> PlayerState {
1527
- guard let player = player else {
1528
- return PlayerState(
1529
- currentTrack: nil,
1530
- currentPosition: 0.0,
1531
- totalDuration: 0.0,
1532
- currentState: .stopped,
1533
- currentPlaylistId: currentPlaylistId.map { Variant_NullType_String.second($0) },
1534
- currentIndex: -1.0,
1535
- currentPlayingType: .notPlaying
1536
- )
1537
- }
1538
-
1539
- let currentTrack = getCurrentTrack()
1540
- let currentPosition = player.currentTime().seconds
1541
- let totalDuration = player.currentItem?.duration.seconds ?? 0.0
1542
-
1543
- let currentState: TrackPlayerState
1544
- if player.rate == 0 {
1545
- currentState = .paused
1546
- } else if player.timeControlStatus == .playing {
1547
- currentState = .playing
1548
- } else {
1549
- currentState = .stopped
1550
- }
1551
-
1552
- // Get current index
1553
- let currentIndex: Double = currentTrackIndex >= 0 ? Double(currentTrackIndex) : -1.0
1554
-
1555
- // Map internal temporary type to CurrentPlayingType
1556
- let currentPlayingType: CurrentPlayingType
1557
- if currentTrack == nil {
1558
- currentPlayingType = .notPlaying
1559
- } else {
1560
- switch currentTemporaryType {
1561
- case .none:
1562
- currentPlayingType = .playlist
1563
- case .playNext:
1564
- currentPlayingType = .playNext
1565
- case .upNext:
1566
- currentPlayingType = .upNext
1567
- }
1568
- }
1569
-
1570
- return PlayerState(
1571
- currentTrack: currentTrack.map { Variant_NullType_TrackItem.second($0) },
1572
- currentPosition: currentPosition,
1573
- totalDuration: totalDuration,
1574
- currentState: currentState,
1575
- currentPlaylistId: currentPlaylistId.map { Variant_NullType_String.second($0) },
1576
- currentIndex: currentIndex,
1577
- currentPlayingType: currentPlayingType
1578
- )
1579
- }
1580
-
1581
- func configure(
1582
- androidAutoEnabled: Bool?,
1583
- carPlayEnabled: Bool?,
1584
- showInNotification: Bool?,
1585
- lookaheadCount: Int? = nil
1586
- ) {
1587
- DispatchQueue.main.async { [weak self] in
1588
- if let lookahead = lookaheadCount {
1589
- self?.lookaheadCount = lookahead
1590
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(lookahead)")
1591
- }
1592
- self?.mediaSessionManager?.configure(
1593
- androidAutoEnabled: androidAutoEnabled,
1594
- carPlayEnabled: carPlayEnabled,
1595
- showInNotification: showInNotification
1596
- )
1597
- }
1598
- }
1599
-
1600
- func getAllPlaylists() -> [Playlist] {
1601
- return playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
1602
- }
1603
-
1604
- // MARK: - Volume Control
1605
-
1606
- func setVolume(volume: Double) -> Bool {
1607
- guard let player = player else {
1608
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Cannot set volume - no player available")
1609
- return false
1610
- }
1611
- DispatchQueue.main.async { [weak self] in
1612
- guard let self = self, let currentPlayer = self.player else {
1613
- return
1614
- }
1615
- // Clamp volume to 0-100 range
1616
- let clampedVolume = max(0.0, min(100.0, volume))
1617
- // Convert to 0.0-1.0 range for AVQueuePlayer
1618
- let normalizedVolume = Float(clampedVolume / 100.0)
1619
- currentPlayer.volume = normalizedVolume
1620
- NitroPlayerLogger.log("TrackPlayerCore", "🔊 Volume set to \(Int(clampedVolume))% (normalized: \(normalizedVolume))")
1621
- }
1622
- return true
1623
- }
1624
-
1625
- func playFromIndex(index: Int) {
1626
- if Thread.isMainThread {
1627
- rebuildQueueFromPlaylistIndex(index: index)
1628
- } else {
1629
- DispatchQueue.main.async { [weak self] in
1630
- self?.rebuildQueueFromPlaylistIndex(index: index)
1631
- }
1632
- }
1633
- }
1634
-
1635
- // MARK: - Skip to Index in Actual Queue
1636
-
1637
- func skipToIndex(index: Int) -> Bool {
1638
- if Thread.isMainThread {
1639
- return skipToIndexInternal(index: index)
1640
- } else {
1641
- var result = false
1642
- DispatchQueue.main.sync { [weak self] in
1643
- result = self?.skipToIndexInternal(index: index) ?? false
1644
- }
1645
- return result
1646
- }
1647
- }
1648
-
1649
- private func skipToIndexInternal(index: Int) -> Bool {
1650
- // Get actual queue to validate index and determine position
1651
- let actualQueue = getActualQueueInternal()
1652
- let totalQueueSize = actualQueue.count
1653
-
1654
- // Validate index
1655
- guard index >= 0 && index < totalQueueSize else { return false }
1656
-
1657
- // Calculate queue section boundaries using effective sizes
1658
- // (reduced by 1 when current track is from that temp list, matching getActualQueueInternal)
1659
- // When temp is playing, the original track at currentTrackIndex is included in "before",
1660
- // so the current playing position shifts by 1
1661
- let currentPos = currentTemporaryType != .none
1662
- ? currentTrackIndex + 1 : currentTrackIndex
1663
- let effectivePlayNextSize = currentTemporaryType == .playNext
1664
- ? max(0, playNextStack.count - 1) : playNextStack.count
1665
- let effectiveUpNextSize = currentTemporaryType == .upNext
1666
- ? max(0, upNextQueue.count - 1) : upNextQueue.count
1667
-
1668
- let playNextStart = currentPos + 1
1669
- let playNextEnd = playNextStart + effectivePlayNextSize
1670
- let upNextStart = playNextEnd
1671
- let upNextEnd = upNextStart + effectiveUpNextSize
1672
- let originalRemainingStart = upNextEnd
1673
-
1674
- // Case 1: Target is before current - rebuild from that playlist index
1675
- if index < currentPos {
1676
- rebuildQueueFromPlaylistIndex(index: index)
1677
- return true
1678
- }
1679
-
1680
- // Case 2: Target is current - seek to beginning
1681
- if index == currentPos {
1682
- player?.seek(to: .zero)
1683
- return true
1684
- }
1685
-
1686
- // Case 3: Target is in playNext section
1687
- if index >= playNextStart && index < playNextEnd {
1688
- let playNextIndex = index - playNextStart
1689
- // Offset by 1 if current is from playNext (index 0 is already playing)
1690
- let actualListIndex = currentTemporaryType == .playNext
1691
- ? playNextIndex + 1 : playNextIndex
1692
-
1693
- // Remove tracks before the target from playNext (they're being skipped)
1694
- if actualListIndex > 0 {
1695
- playNextStack.removeFirst(actualListIndex)
1696
- }
1697
-
1698
- // Rebuild queue and advance
1699
- rebuildAVQueueFromCurrentPosition()
1700
- player?.advanceToNextItem()
1701
- return true
1702
- }
1703
-
1704
- // Case 4: Target is in upNext section
1705
- if index >= upNextStart && index < upNextEnd {
1706
- let upNextIndex = index - upNextStart
1707
- // Offset by 1 if current is from upNext (index 0 is already playing)
1708
- let actualListIndex = currentTemporaryType == .upNext
1709
- ? upNextIndex + 1 : upNextIndex
1710
-
1711
- // Clear all playNext tracks (they're being skipped)
1712
- playNextStack.removeAll()
1713
-
1714
- // Remove tracks before target from upNext
1715
- if actualListIndex > 0 {
1716
- upNextQueue.removeFirst(actualListIndex)
1717
- }
1718
-
1719
- // Rebuild queue and advance
1720
- rebuildAVQueueFromCurrentPosition()
1721
- player?.advanceToNextItem()
1722
- return true
1723
- }
1724
-
1725
- // Case 5: Target is in remaining original tracks
1726
- if index >= originalRemainingStart {
1727
- let targetTrack = actualQueue[index]
1728
-
1729
- // Find this track's index in the original playlist
1730
- guard let originalIndex = currentTracks.firstIndex(where: { $0.id == targetTrack.id }) else {
1731
- return false
1732
- }
1733
-
1734
- // Clear all temporary tracks (they're being skipped)
1735
- playNextStack.removeAll()
1736
- upNextQueue.removeAll()
1737
- currentTemporaryType = .none
1738
-
1739
- let result = rebuildQueueFromPlaylistIndex(index: originalIndex)
1740
-
1741
- // Check if upcoming tracks need URLs
1742
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1743
-
1744
- return result
1745
- }
1746
-
1747
- // Check if upcoming tracks need URLs after any successful skip
1748
- checkUpcomingTracksForUrls(lookahead: lookaheadCount)
1749
-
1750
- return false
1751
- }
1752
-
1753
- /// Clears temporary tracks, rebuilds AVQueuePlayer from `index` in the original playlist,
1754
- /// and resumes playback only if the player was already playing (preserves paused state).
1755
- @discardableResult
1756
- private func rebuildQueueFromPlaylistIndex(index: Int) -> Bool {
1757
- guard index >= 0 && index < self.currentTracks.count else {
1758
- NitroPlayerLogger.log("TrackPlayerCore", "❌ rebuildQueueFromPlaylistIndex - invalid index \(index), currentTracks.count = \(self.currentTracks.count)")
1759
- return false
1760
- }
1761
-
1762
- NitroPlayerLogger.log("TrackPlayerCore", "\n🎯 REBUILD QUEUE FROM PLAYLIST INDEX \(index)")
1763
- NitroPlayerLogger.log("TrackPlayerCore", " Total tracks in playlist: \(self.currentTracks.count)")
1764
- NitroPlayerLogger.log("TrackPlayerCore", " Current index: \(self.currentTrackIndex), target index: \(index)")
1765
-
1766
- // Preserve playback state — only resume if already playing.
1767
- // This prevents auto-starting when called during queue setup (e.g. loadPlaylist → skipToIndex).
1768
- let wasPlaying = self.player?.rate ?? 0 > 0
1769
-
1770
- // Clear temporary tracks when jumping to specific index
1771
- self.playNextStack.removeAll()
1772
- self.upNextQueue.removeAll()
1773
- self.currentTemporaryType = .none
1774
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
1775
-
1776
- // Store the full playlist
1777
- let fullPlaylist = self.currentTracks
1778
-
1779
- // Update currentTrackIndex BEFORE updating queue
1780
- self.currentTrackIndex = index
1781
-
1782
- // Lazy-load guard: if the target track has no URL AND is not downloaded locally,
1783
- // the queue can't be built. Defer to updateTracks once URL resolution completes.
1784
- // Downloaded tracks play from disk via getEffectiveUrl — no remote URL needed.
1785
- let targetTrack = fullPlaylist[index]
1786
- let isLazyLoad = targetTrack.url.isEmpty
1787
- && !DownloadManagerCore.shared.isTrackDownloaded(trackId: targetTrack.id)
1788
- if isLazyLoad {
1789
- NitroPlayerLogger.log("TrackPlayerCore", " ⏳ Lazy-load — deferring AVQueuePlayer setup; emitting track change for index \(index)")
1790
- self.currentTracks = fullPlaylist
1791
- if let track = self.currentTracks[safe: index] {
1792
- notifyTrackChange(track, .skip)
1793
- self.mediaSessionManager?.onTrackChanged()
1794
- }
1795
- return true
1796
- }
1797
-
1798
- // Recreate the queue starting from the target index
1799
- // This ensures all remaining tracks are in the queue
1800
- let tracksToPlay = Array(fullPlaylist[index...])
1801
- NitroPlayerLogger.log("TrackPlayerCore", " 🔄 Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)")
1802
-
1803
- // Create gapless-optimized player items
1804
- let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
1805
- let isPreload = offset < Constants.gaplessPreloadCount
1806
- return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
1807
- }
1808
-
1809
- guard let player = self.player, !items.isEmpty else {
1810
- NitroPlayerLogger.log("TrackPlayerCore", "❌ No player or no items to play")
1811
- return false
1812
- }
1813
-
1814
- // Remove old boundary observer
1815
- if let boundaryObserver = self.boundaryTimeObserver {
1816
- player.removeTimeObserver(boundaryObserver)
1817
- self.boundaryTimeObserver = nil
1818
- }
1819
-
1820
- // Re-enable stall waiting for the new first track so it buffers before playing.
1821
- // Will be flipped back to false once the first item reaches readyToPlay.
1822
- player.automaticallyWaitsToMinimizeStalling = true
1823
-
1824
- // Clear and rebuild queue
1825
- player.removeAllItems()
1826
- var lastItem: AVPlayerItem? = nil
1827
- for item in items {
1828
- player.insert(item, after: lastItem)
1829
- lastItem = item
1830
- }
1831
-
1832
- // Restore the full playlist reference (don't slice it!)
1833
- self.currentTracks = fullPlaylist
1834
-
1835
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
1836
- if let track = self.getCurrentTrack() {
1837
- NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Playing: \(track.title)")
1838
- notifyTrackChange(track, .skip)
1839
- self.mediaSessionManager?.onTrackChanged()
1840
- }
1841
-
1842
- // Start preloading upcoming tracks for gapless playback
1843
- self.preloadUpcomingTracks(from: index + 1)
1844
-
1845
- // Only resume playback if the player was already playing before we rebuilt
1846
- // the loaded playlist. This prevents auto-starting when called during queue setup
1847
- // (e.g. loadPlaylist → skipToIndex).
1848
- if wasPlaying {
1849
- player.play()
1850
- }
1851
- return true
1852
- }
1853
-
1854
- // MARK: - Temporary Track Management
1855
-
1856
- /**
1857
- * Add a track to the up-next queue (FIFO - first added plays first)
1858
- * Track will be inserted after currently playing track and any playNext tracks
1859
- */
1860
- func addToUpNext(trackId: String) {
1861
- DispatchQueue.main.async { [weak self] in
1862
- self?.addToUpNextInternal(trackId: trackId)
1863
- }
1864
- }
1865
-
1866
- private func addToUpNextInternal(trackId: String) {
1867
- NitroPlayerLogger.log("TrackPlayerCore", "📋 addToUpNext(\(trackId))")
1868
-
1869
- // Find the track from current playlist or all playlists
1870
- guard let track = self.findTrackById(trackId) else {
1871
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Track \(trackId) not found")
1872
- return
1873
- }
1874
-
1875
- // Add to end of upNext queue (FIFO)
1876
- self.upNextQueue.append(track)
1877
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
1878
-
1879
- // Rebuild the player queue if actively playing
1880
- if self.player?.currentItem != nil {
1881
- self.rebuildAVQueueFromCurrentPosition()
1882
- }
1883
- mediaSessionManager?.onQueueChanged()
1884
- }
1885
-
1886
- /**
1887
- * Add a track to play next (LIFO - last added plays first)
1888
- * Track will be inserted immediately after currently playing track
1889
- */
1890
- func playNext(trackId: String) {
1891
- DispatchQueue.main.async { [weak self] in
1892
- self?.playNextInternal(trackId: trackId)
1893
- }
1894
- }
1895
-
1896
- private func playNextInternal(trackId: String) {
1897
- NitroPlayerLogger.log("TrackPlayerCore", "⏭️ playNext(\(trackId))")
1898
-
1899
- // Find the track from current playlist or all playlists
1900
- guard let track = self.findTrackById(trackId) else {
1901
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Track \(trackId) not found")
1902
- return
1903
- }
1904
-
1905
- // Insert at beginning of playNext stack (LIFO)
1906
- self.playNextStack.insert(track, at: 0)
1907
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to playNext stack (position: 1)")
1908
-
1909
- // Rebuild the player queue if actively playing
1910
- if self.player?.currentItem != nil {
1911
- self.rebuildAVQueueFromCurrentPosition()
1912
- }
1913
- mediaSessionManager?.onQueueChanged()
1914
- }
1915
-
1916
- /**
1917
- * Rebuild the AVQueuePlayer from current position with temporary tracks
1918
- * Order: [current] + [playNext stack reversed] + [upNext queue] + [remaining original]
1919
- *
1920
- * - Parameter changedTrackIds: When non-nil, performs a **surgical** update:
1921
- * only AVPlayerItems whose track ID is in this set are removed and re-created.
1922
- * All other pre-buffered items are left in place and new items are inserted
1923
- * around them. This preserves AVQueuePlayer's internal audio pre-roll buffers
1924
- * for gapless inter-track transitions.
1925
- * When nil, the queue is fully torn down and rebuilt (used by skip, reorder,
1926
- * addToUpNext, playNext, etc.).
1927
- */
1928
- private func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
1929
- guard let player = self.player else { return }
1930
-
1931
- let currentItem = player.currentItem
1932
- let playingItems = player.items()
1933
-
1934
- // ---- Handle removed-current-track case ----
1935
- // If the currently playing AVPlayerItem is no longer in currentTracks (e.g. the user
1936
- // removed it while it was playing), delegate to rebuildQueueFromPlaylistIndex so the
1937
- // player immediately starts what is now at currentTrackIndex in the updated list.
1938
- if let playingTrackId = currentItem?.trackId,
1939
- !currentTracks.contains(where: { $0.id == playingTrackId }) {
1940
- let targetIndex = currentTrackIndex < currentTracks.count
1941
- ? currentTrackIndex
1942
- : currentTracks.count - 1
1943
- if targetIndex >= 0 {
1944
- _ = rebuildQueueFromPlaylistIndex(index: targetIndex)
1945
- }
1946
- return
1947
- }
1948
-
1949
- // ---- Build the desired upcoming track list ----
1950
-
1951
- var newQueueTracks: [TrackItem] = []
1952
-
1953
- // Add playNext stack (LIFO - most recently added plays first)
1954
- if currentTemporaryType == .playNext && playNextStack.count > 1 {
1955
- newQueueTracks.append(contentsOf: playNextStack.dropFirst())
1956
- } else if currentTemporaryType != .playNext {
1957
- newQueueTracks.append(contentsOf: playNextStack)
1958
- }
1959
-
1960
- // Add upNext queue (in order, FIFO)
1961
- if currentTemporaryType == .upNext && upNextQueue.count > 1 {
1962
- newQueueTracks.append(contentsOf: upNextQueue.dropFirst())
1963
- } else if currentTemporaryType != .upNext {
1964
- newQueueTracks.append(contentsOf: upNextQueue)
1965
- }
1966
-
1967
- // Add remaining original tracks
1968
- if currentTrackIndex + 1 < currentTracks.count {
1969
- newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
1970
- }
1971
-
1972
- // ---- Collect existing upcoming AVPlayerItems ----
1973
-
1974
- let upcomingItems: [AVPlayerItem]
1975
- if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
1976
- upcomingItems = Array(playingItems.suffix(from: playingItems.index(after: ciIndex)))
1977
- } else {
1978
- upcomingItems = []
1979
- }
1980
-
1981
- let existingIds = upcomingItems.compactMap { $0.trackId }
1982
- let desiredIds = newQueueTracks.map { $0.id }
1983
-
1984
- // ---- Fast-path: nothing to do if queue already matches ----
1985
-
1986
- if existingIds == desiredIds {
1987
- if let changedIds = changedTrackIds {
1988
- if Set(existingIds).isDisjoint(with: changedIds) {
1989
- NitroPlayerLogger.log("TrackPlayerCore",
1990
- "✅ Queue matches & no buffered URLs changed — preserving \(existingIds.count) items for gapless")
1991
- return
1992
- }
1993
- } else {
1994
- NitroPlayerLogger.log("TrackPlayerCore",
1995
- "✅ Queue already matches desired order — preserving \(existingIds.count) items for gapless")
1996
- return
1997
- }
1998
- }
1999
-
2000
- // ---- Surgical path (changedTrackIds provided, e.g. from updateTracks) ----
2001
- // Only remove items whose URLs actually changed; insert newly-resolved items
2002
- // in the correct positions around existing, pre-buffered items.
2003
-
2004
- if let changedIds = changedTrackIds {
2005
- // Build lookup of reusable (un-changed) items by track ID
2006
- var reusableByTrackId: [String: AVPlayerItem] = [:]
2007
- for item in upcomingItems {
2008
- if let trackId = item.trackId, !changedIds.contains(trackId) {
2009
- reusableByTrackId[trackId] = item
2010
- }
2011
- }
2012
-
2013
- // Remove only items whose URLs changed
2014
- let desiredIdSet = Set(desiredIds)
2015
- for item in upcomingItems {
2016
- guard let trackId = item.trackId else { continue }
2017
- if changedIds.contains(trackId) || !desiredIdSet.contains(trackId) {
2018
- player.remove(item)
2019
- }
2020
- }
2021
-
2022
- // Walk through the desired order, inserting new items around the
2023
- // reusable items that are still sitting in the queue untouched.
2024
- var lastAnchor: AVPlayerItem? = currentItem
2025
- for trackId in desiredIds {
2026
- if let reusable = reusableByTrackId[trackId] {
2027
- // Item is still in the queue at its original position — advance anchor
2028
- lastAnchor = reusable
2029
- } else if let track = newQueueTracks.first(where: { $0.id == trackId }),
2030
- let newItem = createGaplessPlayerItem(for: track, isPreload: false)
2031
- {
2032
- player.insert(newItem, after: lastAnchor)
2033
- lastAnchor = newItem
2034
- }
2035
- }
2036
-
2037
- let preserved = reusableByTrackId.count
2038
- let inserted = desiredIds.count - preserved
2039
- NitroPlayerLogger.log("TrackPlayerCore",
2040
- "🔄 Surgical rebuild: preserved \(preserved) buffered items, inserted \(inserted) new items")
2041
- return
2042
- }
2043
-
2044
- // ---- Full rebuild path (no changedTrackIds — skip, reorder, etc.) ----
2045
-
2046
- for item in playingItems where item != currentItem {
2047
- player.remove(item)
2048
- }
2049
-
2050
- var lastItem = currentItem
2051
- for track in newQueueTracks {
2052
- if let item = createGaplessPlayerItem(for: track, isPreload: false) {
2053
- player.insert(item, after: lastItem)
2054
- lastItem = item
2055
- }
2056
- }
2057
- }
2058
-
2059
- /**
2060
- * Find a track by ID from current playlist or all playlists
2061
- */
2062
- private func findTrackById(_ trackId: String) -> TrackItem? {
2063
- // First check current playlist
2064
- if let track = currentTracks.first(where: { $0.id == trackId }) {
2065
- return track
2066
- }
2067
-
2068
- // Then check all playlists
2069
- let allPlaylists = playlistManager.getAllPlaylists()
2070
- for playlist in allPlaylists {
2071
- if let track = playlist.tracks.first(where: { $0.id == trackId }) {
2072
- return track
2073
- }
2074
- }
2075
-
2076
- return nil
2077
- }
2078
-
2079
- /**
2080
- * Determine what type of track is currently playing
2081
- */
2082
- private func determineCurrentTemporaryType() -> TemporaryType {
2083
- guard let currentItem = player?.currentItem,
2084
- let trackId = currentItem.trackId
2085
- else {
2086
- return .none
2087
- }
2088
-
2089
- // Check if in playNext stack
2090
- if playNextStack.contains(where: { $0.id == trackId }) {
2091
- return .playNext
2092
- }
2093
-
2094
- // Check if in upNext queue
2095
- if upNextQueue.contains(where: { $0.id == trackId }) {
2096
- return .upNext
2097
- }
2098
-
2099
- return .none
2100
- }
2101
-
2102
- // MARK: - Lazy URL Loading Support
2103
-
2104
- /**
2105
- * Update entire track objects and rebuild queue if needed
2106
- * Skips currently playing track to preserve gapless playback
2107
- * CRITICAL: Invalidates preloaded assets and re-preloads for gapless
2108
- */
2109
- func updateTracks(tracks: [TrackItem]) {
2110
- DispatchQueue.main.async { [weak self] in
2111
- guard let self = self else { return }
2112
-
2113
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 updateTracks: \(tracks.count) updates")
2114
-
2115
- // Get current track to decide how to handle it
2116
- let currentTrack = self.getCurrentTrack()
2117
- let currentTrackId = currentTrack?.id
2118
- // A track is only "empty" if it has no remote URL AND is not downloaded.
2119
- // Downloaded tracks with empty .url are playing from disk — don't replace them.
2120
- let currentTrackIsEmpty = currentTrack.map {
2121
- $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2122
- } ?? false
2123
-
2124
- // Filter out current track and validate
2125
- let safeTracks = tracks.filter { track in
2126
- switch true {
2127
- case track.id == currentTrackId && !currentTrackIsEmpty:
2128
- // Has a real URL already — skip to preserve gapless playback
2129
- NitroPlayerLogger.log(
2130
- "TrackPlayerCore",
2131
- "⚠️ Skipping update for currently playing track: \(track.id) (preserves gapless)")
2132
- return false
2133
- case track.id == currentTrackId && currentTrackIsEmpty:
2134
- // Empty URL — must not be playing, allow the update (only if the new URL is real)
2135
- NitroPlayerLogger.log(
2136
- "TrackPlayerCore",
2137
- "🔄 Updating current track with no URL: \(track.id)")
2138
- return !track.url.isEmpty
2139
- case track.url.isEmpty:
2140
- NitroPlayerLogger.log(
2141
- "TrackPlayerCore", "⚠️ Skipping track with empty URL: \(track.id)")
2142
- return false
2143
- default:
2144
- return true
2145
- }
2146
- }
2147
-
2148
- guard !safeTracks.isEmpty else {
2149
- NitroPlayerLogger.log("TrackPlayerCore", "✅ No valid updates to apply")
2150
- return
2151
- }
2152
-
2153
- // Invalidate preloaded assets for tracks with updated data
2154
- // This is CRITICAL for gapless playback - old assets might use old URLs
2155
- let updatedTrackIds = Set(safeTracks.map { $0.id })
2156
- for trackId in updatedTrackIds {
2157
- if self.preloadedAssets[trackId] != nil {
2158
- NitroPlayerLogger.log(
2159
- "TrackPlayerCore", "🗑️ Invalidating preloaded asset for track: \(trackId)")
2160
- self.preloadedAssets.removeValue(forKey: trackId)
2161
- }
2162
- }
2163
-
2164
- // Update in PlaylistManager
2165
- let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
2166
-
2167
- // If the current track had no URL and now has one, replace the current AVPlayerItem
2168
- if let update = currentTrack, currentTrackIsEmpty, !update.url.isEmpty {
2169
- NitroPlayerLogger.log(
2170
- "TrackPlayerCore", "🔄 Replacing current AVPlayerItem for track with resolved URL: \(update.id)")
2171
- if let newItem = self.createGaplessPlayerItem(for: update, isPreload: false) {
2172
- self.player?.replaceCurrentItem(with: newItem)
2173
- }
2174
- }
2175
-
2176
- // Rebuild queue if current playlist was affected
2177
- if let currentId = self.currentPlaylistId,
2178
- let updateCount = affectedPlaylists[currentId]
2179
- {
2180
- NitroPlayerLogger.log(
2181
- "TrackPlayerCore",
2182
- "🔄 Rebuilding queue - \(updateCount) tracks updated in current playlist")
2183
-
2184
- // Sync currentTracks from the freshly-updated PlaylistManager so rebuilds use resolved URLs
2185
- if let updatedPlaylist = self.playlistManager.getPlaylist(playlistId: currentId) {
2186
- self.currentTracks = updatedPlaylist.tracks
2187
- NitroPlayerLogger.log("TrackPlayerCore", "📥 Synced currentTracks from PlaylistManager (\(self.currentTracks.count) tracks)")
2188
- }
2189
-
2190
- if self.player?.currentItem == nil, let player = self.player {
2191
- // No AVPlayerItem exists yet — lazy-load mode: URLs were empty when the queue first
2192
- // loaded. Rebuild the full queue from currentTrackIndex now that URLs are resolved.
2193
- NitroPlayerLogger.log(
2194
- "TrackPlayerCore",
2195
- "🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
2196
- player.removeAllItems()
2197
- var lastItem: AVPlayerItem? = nil
2198
- for (offset, track) in self.currentTracks[self.currentTrackIndex...].enumerated() {
2199
- let isPreload = offset < Constants.gaplessPreloadCount
2200
- if let newItem = self.createGaplessPlayerItem(for: track, isPreload: isPreload) {
2201
- player.insert(newItem, after: lastItem)
2202
- lastItem = newItem
2203
- }
2204
- }
2205
- player.play()
2206
- self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
2207
- } else {
2208
- // A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
2209
- // Pass the set of track IDs whose URLs actually changed so the rebuild
2210
- // can keep already-buffered items intact for gapless transitions.
2211
- self.rebuildAVQueueFromCurrentPosition(changedTrackIds: updatedTrackIds)
2212
- // Re-preload upcoming tracks for gapless playback
2213
- // CRITICAL: This restores gapless buffering after queue rebuild
2214
- self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
2215
- }
2216
-
2217
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Queue rebuilt, gapless playback preserved")
2218
- }
2219
-
2220
- NitroPlayerLogger.log(
2221
- "TrackPlayerCore",
2222
- "✅ Track updates complete - \(affectedPlaylists.count) playlists affected")
2223
- }
2224
- }
2225
-
2226
- /**
2227
- * Get tracks by IDs from all playlists
2228
- */
2229
- func getTracksById(trackIds: [String]) -> [TrackItem] {
2230
- if Thread.isMainThread {
2231
- return playlistManager.getTracksById(trackIds: trackIds)
2232
- } else {
2233
- var tracks: [TrackItem] = []
2234
- DispatchQueue.main.sync { [weak self] in
2235
- tracks = self?.playlistManager.getTracksById(trackIds: trackIds) ?? []
2236
- }
2237
- return tracks
2238
- }
2239
- }
2240
-
2241
- /**
2242
- * Get tracks needing URLs from current playlist
2243
- */
2244
- func getTracksNeedingUrls() -> [TrackItem] {
2245
- if Thread.isMainThread {
2246
- return getTracksNeedingUrlsInternal()
2247
- } else {
2248
- var tracks: [TrackItem] = []
2249
- DispatchQueue.main.sync { [weak self] in
2250
- tracks = self?.getTracksNeedingUrlsInternal() ?? []
2251
- }
2252
- return tracks
2253
- }
2254
- }
2255
-
2256
- private func getTracksNeedingUrlsInternal() -> [TrackItem] {
2257
- guard let currentId = currentPlaylistId,
2258
- let playlist = playlistManager.getPlaylist(playlistId: currentId)
2259
- else {
2260
- return []
2261
- }
2262
-
2263
- // Only return tracks that truly can't play: empty remote URL AND not
2264
- // downloaded locally. Downloaded tracks play from disk via getEffectiveUrl.
2265
- return playlist.tracks.filter {
2266
- $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2267
- }
2268
- }
2269
-
2270
- /**
2271
- * Get next N tracks from current position
2272
- */
2273
- func getNextTracks(count: Int) -> [TrackItem] {
2274
- if Thread.isMainThread {
2275
- return getNextTracksInternal(count: count)
2276
- } else {
2277
- var tracks: [TrackItem] = []
2278
- DispatchQueue.main.sync { [weak self] in
2279
- tracks = self?.getNextTracksInternal(count: count) ?? []
2280
- }
2281
- return tracks
2282
- }
2283
- }
2284
-
2285
- private func getNextTracksInternal(count: Int) -> [TrackItem] {
2286
- let actualQueue = getActualQueueInternal()
2287
- guard !actualQueue.isEmpty else { return [] }
2288
-
2289
- guard let currentTrack = getCurrentTrack(),
2290
- let currentIndex = actualQueue.firstIndex(where: { $0.id == currentTrack.id })
2291
- else {
2292
- return []
2293
- }
2294
-
2295
- let startIndex = currentIndex + 1
2296
- let endIndex = min(startIndex + count, actualQueue.count)
2297
-
2298
- return startIndex < actualQueue.count ? Array(actualQueue[startIndex..<endIndex]) : []
2299
- }
2300
-
2301
- /**
2302
- * Get current track index in playlist
2303
- */
2304
- func getCurrentTrackIndex() -> Int {
2305
- if Thread.isMainThread {
2306
- return currentTrackIndex
2307
- } else {
2308
- var index = -1
2309
- DispatchQueue.main.sync { [weak self] in
2310
- index = self?.currentTrackIndex ?? -1
2311
- }
2312
- return index
2313
- }
2314
- }
2315
-
2316
- /**
2317
- * Callback for tracks needing update
2318
- */
2319
- typealias OnTracksNeedUpdateCallback = ([TrackItem], Int) -> Void
2320
-
2321
- // Add to class properties
2322
- private var onTracksNeedUpdateListeners: [(callback: OnTracksNeedUpdateCallback, isAlive: Bool)] =
2323
- []
2324
- private let tracksNeedUpdateQueue = DispatchQueue(
2325
- label: "com.nitroplayer.tracksneedupdate", attributes: .concurrent)
2326
-
2327
- /**
2328
- * Register listener for when tracks need update
2329
- */
2330
- func addOnTracksNeedUpdateListener(callback: @escaping OnTracksNeedUpdateCallback) {
2331
- tracksNeedUpdateQueue.async(flags: .barrier) { [weak self] in
2332
- self?.onTracksNeedUpdateListeners.append((callback: callback, isAlive: true))
2333
- }
2334
- }
2335
-
2336
- /**
2337
- * Notify listeners that tracks need updating
2338
- */
2339
- private func notifyTracksNeedUpdate(tracks: [TrackItem], lookahead: Int) {
2340
- tracksNeedUpdateQueue.async(flags: .barrier) { [weak self] in
2341
- guard let self = self else { return }
2342
-
2343
- // Clean up dead listeners
2344
- self.onTracksNeedUpdateListeners.removeAll { !$0.isAlive }
2345
- let liveCallbacks = self.onTracksNeedUpdateListeners.map { $0.callback }
2346
-
2347
- if !liveCallbacks.isEmpty {
2348
- DispatchQueue.main.async {
2349
- for callback in liveCallbacks {
2350
- callback(tracks, lookahead)
2351
- }
2352
- }
2353
- }
2354
- }
2355
- }
2356
-
2357
- /**
2358
- * Check if upcoming tracks need URLs and notify listeners
2359
- * Call this in playerItemDidPlayToEndTime or after skip operations
2360
- */
2361
- private func checkUpcomingTracksForUrls(lookahead: Int = 5) {
2362
- let upcomingTracks = getNextTracksInternal(count: lookahead)
2363
-
2364
- // Always include the current track if it has no URL and isn't downloaded — it can't play without one
2365
- let currentTrack = getCurrentTrack()
2366
- let currentNeedsUrl = currentTrack.map {
2367
- $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2368
- } ?? false
2369
- let candidateTracks = currentNeedsUrl ? [currentTrack!] + upcomingTracks : upcomingTracks
2370
-
2371
- // Only request URLs for tracks that truly can't play: empty remote URL
2372
- // AND not downloaded locally (downloaded tracks play from disk via getEffectiveUrl).
2373
- let tracksNeedingUrls = candidateTracks.filter {
2374
- $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2375
- }
2376
-
2377
- if !tracksNeedingUrls.isEmpty {
2378
- NitroPlayerLogger.log(
2379
- "TrackPlayerCore", "⚠️ \(tracksNeedingUrls.count) upcoming tracks need URLs")
2380
- notifyTracksNeedUpdate(tracks: tracksNeedingUrls, lookahead: lookahead)
2381
- }
2382
- }
2383
-
2384
- // MARK: - Cleanup
2385
-
2386
- deinit {
2387
- NitroPlayerLogger.log("TrackPlayerCore", "🧹 Cleaning up...")
2388
-
2389
- // Clear preloaded assets for gapless playback
2390
- preloadedAssets.removeAll()
2391
-
2392
- // Remove boundary time observer
2393
- if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
2394
- currentPlayer.removeTimeObserver(boundaryObserver)
2395
- }
2396
-
2397
- // Clear item observers (modern KVO automatically releases)
2398
- currentItemObservers.removeAll()
2399
-
2400
- // Remove player KVO observers (these were added in setupPlayer)
2401
- if let currentPlayer = player {
2402
- currentPlayer.removeObserver(self, forKeyPath: "status")
2403
- currentPlayer.removeObserver(self, forKeyPath: "rate")
2404
- currentPlayer.removeObserver(self, forKeyPath: "timeControlStatus")
2405
- currentPlayer.removeObserver(self, forKeyPath: "currentItem")
2406
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Player observers removed")
2407
- }
2408
-
2409
- // Remove all notification observers
2410
- NotificationCenter.default.removeObserver(self)
2411
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Cleanup complete")
2412
- }
2413
- }
2414
-
2415
- // Safe array access extension
2416
- extension Array {
2417
- subscript(safe index: Int) -> Element? {
2418
- return indices.contains(index) ? self[index] : nil
2419
- }
2420
- }
2421
-
2422
- // Associated object extension for AVPlayerItem
201
+ // Associated object for AVPlayerItem trackId
2423
202
  private var trackIdKey: UInt8 = 0
2424
-
2425
203
  extension AVPlayerItem {
2426
204
  var trackId: String? {
2427
- get {
2428
- return objc_getAssociatedObject(self, &trackIdKey) as? String
2429
- }
2430
- set {
2431
- objc_setAssociatedObject(self, &trackIdKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
2432
- }
205
+ get { objc_getAssociatedObject(self, &trackIdKey) as? String }
206
+ set { objc_setAssociatedObject(self, &trackIdKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
2433
207
  }
2434
208
  }