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.
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +165 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +161 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +11 -58
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
- package/ios/HybridDownloadManager.swift +32 -26
- package/ios/HybridEqualizer.swift +48 -35
- package/ios/HybridTrackPlayer.swift +127 -102
- package/ios/core/ListenerRegistry.swift +60 -0
- package/ios/core/TrackPlayerCore.swift +130 -2356
- package/ios/core/TrackPlayerListener.swift +395 -0
- package/ios/core/TrackPlayerNotify.swift +52 -0
- package/ios/core/TrackPlayerPlayback.swift +274 -0
- package/ios/core/TrackPlayerQueue.swift +212 -0
- package/ios/core/TrackPlayerQueueBuild.swift +482 -0
- package/ios/core/TrackPlayerTempQueue.swift +167 -0
- package/ios/core/TrackPlayerUrlLoader.swift +169 -0
- package/ios/equalizer/EqualizerCore.swift +24 -89
- package/ios/media/MediaSessionManager.swift +32 -49
- package/ios/playlist/PlaylistManager.swift +2 -9
- package/ios/queue/HybridPlayerQueue.swift +69 -66
- package/lib/hooks/useDownloadedTracks.js +16 -13
- package/lib/hooks/useEqualizer.d.ts +4 -4
- package/lib/hooks/useEqualizer.js +12 -12
- package/lib/hooks/useEqualizerPresets.d.ts +3 -3
- package/lib/hooks/useEqualizerPresets.js +12 -18
- package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
- package/lib/specs/AudioDevices.nitro.d.ts +2 -2
- package/lib/specs/DownloadManager.nitro.d.ts +10 -10
- package/lib/specs/Equalizer.nitro.d.ts +9 -9
- package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +31 -6
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +2 -2
- package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +16 -3
- package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +1 -1
- package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +154 -44
- package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +10 -10
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +130 -34
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +9 -9
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +115 -24
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +8 -8
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +243 -24
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +16 -8
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +3 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +2 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +10 -10
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +10 -9
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +9 -8
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +45 -8
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
- package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
- package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
- package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
- package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
- package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
- package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
- package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
- package/package.json +1 -1
- package/src/hooks/useDownloadedTracks.ts +17 -13
- package/src/hooks/useEqualizer.ts +16 -16
- package/src/hooks/useEqualizerPresets.ts +15 -21
- package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
- package/src/specs/AudioDevices.nitro.ts +2 -2
- package/src/specs/DownloadManager.nitro.ts +10 -10
- package/src/specs/Equalizer.nitro.ts +9 -9
- 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
|
|
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
|
-
|
|
27
|
-
static let
|
|
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
|
-
|
|
36
|
-
static let
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// MARK: -
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
return playlistManager
|
|
132
|
+
@discardableResult func removeOnPlaybackStateChangeListener(id: Int64) -> Bool {
|
|
133
|
+
onPlaybackStateChangeListeners.remove(id: id)
|
|
693
134
|
}
|
|
694
135
|
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
923
|
-
|
|
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
|
-
|
|
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: -
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
let
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|