react-native-nitro-player 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/NitroPlayer.podspec +31 -0
  2. package/README.md +610 -0
  3. package/android/CMakeLists.txt +29 -0
  4. package/android/build.gradle +147 -0
  5. package/android/fix-prefab.gradle +51 -0
  6. package/android/gradle.properties +5 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +7 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +29 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +116 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +167 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +93 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/NitroPlayerPackage.kt +21 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +171 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +639 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +352 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +58 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +77 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +73 -0
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +506 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/Playlist.kt +21 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +454 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +94 -0
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +143 -0
  25. package/ios/HybridAudioRoutePicker.swift +53 -0
  26. package/ios/HybridTrackPlayer.swift +100 -0
  27. package/ios/core/TrackPlayerCore.swift +1040 -0
  28. package/ios/media/MediaSessionManager.swift +230 -0
  29. package/ios/playlist/PlaylistManager.swift +446 -0
  30. package/ios/playlist/PlaylistModel.swift +49 -0
  31. package/ios/queue/HybridPlayerQueue.swift +95 -0
  32. package/ios/queue/Queue.swift +126 -0
  33. package/ios/queue/QueueManager.swift +157 -0
  34. package/lib/hooks/index.d.ts +6 -0
  35. package/lib/hooks/index.js +6 -0
  36. package/lib/hooks/useAndroidAutoConnection.d.ts +13 -0
  37. package/lib/hooks/useAndroidAutoConnection.js +26 -0
  38. package/lib/hooks/useAudioDevices.d.ts +26 -0
  39. package/lib/hooks/useAudioDevices.js +55 -0
  40. package/lib/hooks/useOnChangeTrack.d.ts +9 -0
  41. package/lib/hooks/useOnChangeTrack.js +17 -0
  42. package/lib/hooks/useOnPlaybackProgressChange.d.ts +9 -0
  43. package/lib/hooks/useOnPlaybackProgressChange.js +19 -0
  44. package/lib/hooks/useOnPlaybackStateChange.d.ts +9 -0
  45. package/lib/hooks/useOnPlaybackStateChange.js +17 -0
  46. package/lib/hooks/useOnSeek.d.ts +8 -0
  47. package/lib/hooks/useOnSeek.js +17 -0
  48. package/lib/index.d.ts +14 -0
  49. package/lib/index.js +24 -0
  50. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +21 -0
  51. package/lib/specs/AndroidAutoMediaLibrary.nitro.js +1 -0
  52. package/lib/specs/AudioDevices.nitro.d.ts +24 -0
  53. package/lib/specs/AudioDevices.nitro.js +1 -0
  54. package/lib/specs/AudioRoutePicker.nitro.d.ts +10 -0
  55. package/lib/specs/AudioRoutePicker.nitro.js +1 -0
  56. package/lib/specs/TrackPlayer.nitro.d.ts +39 -0
  57. package/lib/specs/TrackPlayer.nitro.js +1 -0
  58. package/lib/types/AndroidAutoMediaLibrary.d.ts +44 -0
  59. package/lib/types/AndroidAutoMediaLibrary.js +1 -0
  60. package/lib/types/PlayerQueue.d.ts +32 -0
  61. package/lib/types/PlayerQueue.js +1 -0
  62. package/lib/utils/androidAutoMediaLibrary.d.ts +47 -0
  63. package/lib/utils/androidAutoMediaLibrary.js +62 -0
  64. package/nitro.json +31 -0
  65. package/nitrogen/generated/.gitattributes +1 -0
  66. package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +91 -0
  67. package/nitrogen/generated/android/NitroPlayer+autolinking.gradle +27 -0
  68. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +88 -0
  69. package/nitrogen/generated/android/NitroPlayerOnLoad.hpp +25 -0
  70. package/nitrogen/generated/android/c++/JFunc_void_TrackItem_std__optional_Reason_.hpp +85 -0
  71. package/nitrogen/generated/android/c++/JFunc_void_TrackPlayerState_std__optional_Reason_.hpp +80 -0
  72. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  73. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  74. package/nitrogen/generated/android/c++/JFunc_void_double_double_std__optional_bool_.hpp +76 -0
  75. package/nitrogen/generated/android/c++/JFunc_void_std__string_Playlist_std__optional_QueueOperation_.hpp +88 -0
  76. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Playlist__std__optional_QueueOperation_.hpp +106 -0
  77. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +55 -0
  78. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +66 -0
  79. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +70 -0
  80. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +66 -0
  81. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +143 -0
  82. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +77 -0
  83. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +137 -0
  84. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +78 -0
  85. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +65 -0
  86. package/nitrogen/generated/android/c++/JPlayerState.hpp +87 -0
  87. package/nitrogen/generated/android/c++/JPlaylist.hpp +99 -0
  88. package/nitrogen/generated/android/c++/JQueueOperation.hpp +65 -0
  89. package/nitrogen/generated/android/c++/JReason.hpp +65 -0
  90. package/nitrogen/generated/android/c++/JTAudioDevice.hpp +69 -0
  91. package/nitrogen/generated/android/c++/JTrackItem.hpp +86 -0
  92. package/nitrogen/generated/android/c++/JTrackPlayerState.hpp +62 -0
  93. package/nitrogen/generated/android/c++/JVariant_NullType_Playlist.cpp +26 -0
  94. package/nitrogen/generated/android/c++/JVariant_NullType_Playlist.hpp +77 -0
  95. package/nitrogen/generated/android/c++/JVariant_NullType_String.cpp +26 -0
  96. package/nitrogen/generated/android/c++/JVariant_NullType_String.hpp +70 -0
  97. package/nitrogen/generated/android/c++/JVariant_NullType_TrackItem.cpp +26 -0
  98. package/nitrogen/generated/android/c++/JVariant_NullType_TrackItem.hpp +74 -0
  99. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackItem_std__optional_Reason_.kt +80 -0
  100. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackPlayerState_std__optional_Reason_.kt +80 -0
  101. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_bool.kt +80 -0
  102. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_double_double.kt +80 -0
  103. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_double_double_std__optional_bool_.kt +80 -0
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_Playlist_std__optional_QueueOperation_.kt +80 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_Playlist__std__optional_QueueOperation_.kt +80 -0
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +61 -0
  107. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +61 -0
  108. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +116 -0
  109. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +134 -0
  110. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/NitroPlayerOnLoad.kt +35 -0
  111. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +44 -0
  112. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerState.kt +53 -0
  113. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Playlist.kt +50 -0
  114. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/QueueOperation.kt +23 -0
  115. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Reason.kt +23 -0
  116. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TAudioDevice.kt +47 -0
  117. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TrackItem.kt +56 -0
  118. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TrackPlayerState.kt +22 -0
  119. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_Playlist.kt +59 -0
  120. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_String.kt +59 -0
  121. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_TrackItem.kt +59 -0
  122. package/nitrogen/generated/ios/NitroPlayer+autolinking.rb +60 -0
  123. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +123 -0
  124. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +531 -0
  125. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +80 -0
  126. package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +49 -0
  127. package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +55 -0
  128. package/nitrogen/generated/ios/c++/HybridAudioRoutePickerSpecSwift.cpp +11 -0
  129. package/nitrogen/generated/ios/c++/HybridAudioRoutePickerSpecSwift.hpp +74 -0
  130. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.cpp +11 -0
  131. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +167 -0
  132. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.cpp +11 -0
  133. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +174 -0
  134. package/nitrogen/generated/ios/swift/Func_void_TrackItem_std__optional_Reason_.swift +47 -0
  135. package/nitrogen/generated/ios/swift/Func_void_TrackPlayerState_std__optional_Reason_.swift +47 -0
  136. package/nitrogen/generated/ios/swift/Func_void_bool.swift +47 -0
  137. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +47 -0
  138. package/nitrogen/generated/ios/swift/Func_void_double_double_std__optional_bool_.swift +54 -0
  139. package/nitrogen/generated/ios/swift/Func_void_std__string_Playlist_std__optional_QueueOperation_.swift +47 -0
  140. package/nitrogen/generated/ios/swift/Func_void_std__vector_Playlist__std__optional_QueueOperation_.swift +47 -0
  141. package/nitrogen/generated/ios/swift/HybridAudioRoutePickerSpec.swift +56 -0
  142. package/nitrogen/generated/ios/swift/HybridAudioRoutePickerSpec_cxx.swift +130 -0
  143. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +68 -0
  144. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +349 -0
  145. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +69 -0
  146. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +325 -0
  147. package/nitrogen/generated/ios/swift/PlayerConfig.swift +115 -0
  148. package/nitrogen/generated/ios/swift/PlayerState.swift +181 -0
  149. package/nitrogen/generated/ios/swift/Playlist.swift +182 -0
  150. package/nitrogen/generated/ios/swift/QueueOperation.swift +48 -0
  151. package/nitrogen/generated/ios/swift/Reason.swift +48 -0
  152. package/nitrogen/generated/ios/swift/TrackItem.swift +147 -0
  153. package/nitrogen/generated/ios/swift/TrackPlayerState.swift +44 -0
  154. package/nitrogen/generated/ios/swift/Variant_NullType_Playlist.swift +18 -0
  155. package/nitrogen/generated/ios/swift/Variant_NullType_String.swift +18 -0
  156. package/nitrogen/generated/ios/swift/Variant_NullType_TrackItem.swift +18 -0
  157. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +22 -0
  158. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +63 -0
  159. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.cpp +22 -0
  160. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +65 -0
  161. package/nitrogen/generated/shared/c++/HybridAudioRoutePickerSpec.cpp +21 -0
  162. package/nitrogen/generated/shared/c++/HybridAudioRoutePickerSpec.hpp +62 -0
  163. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +33 -0
  164. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +87 -0
  165. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +34 -0
  166. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +91 -0
  167. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +83 -0
  168. package/nitrogen/generated/shared/c++/PlayerState.hpp +103 -0
  169. package/nitrogen/generated/shared/c++/Playlist.hpp +97 -0
  170. package/nitrogen/generated/shared/c++/QueueOperation.hpp +84 -0
  171. package/nitrogen/generated/shared/c++/Reason.hpp +84 -0
  172. package/nitrogen/generated/shared/c++/TAudioDevice.hpp +87 -0
  173. package/nitrogen/generated/shared/c++/TrackItem.hpp +102 -0
  174. package/nitrogen/generated/shared/c++/TrackPlayerState.hpp +80 -0
  175. package/package.json +172 -0
  176. package/react-native.config.js +16 -0
  177. package/src/hooks/index.ts +6 -0
  178. package/src/hooks/useAndroidAutoConnection.ts +30 -0
  179. package/src/hooks/useAudioDevices.ts +64 -0
  180. package/src/hooks/useOnChangeTrack.ts +24 -0
  181. package/src/hooks/useOnPlaybackProgressChange.ts +30 -0
  182. package/src/hooks/useOnPlaybackStateChange.ts +24 -0
  183. package/src/hooks/useOnSeek.ts +25 -0
  184. package/src/index.ts +47 -0
  185. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +22 -0
  186. package/src/specs/AudioDevices.nitro.ts +25 -0
  187. package/src/specs/AudioRoutePicker.nitro.ts +9 -0
  188. package/src/specs/TrackPlayer.nitro.ts +81 -0
  189. package/src/types/AndroidAutoMediaLibrary.ts +58 -0
  190. package/src/types/PlayerQueue.ts +38 -0
  191. package/src/utils/androidAutoMediaLibrary.ts +66 -0
@@ -0,0 +1,1040 @@
1
+ //
2
+ // TrackPlayerCore.swift
3
+ // NitroPlayer
4
+ //
5
+ // Created by Ritesh Shukla on 10/12/25.
6
+ //
7
+
8
+ import AVFoundation
9
+ import Foundation
10
+ import MediaPlayer
11
+ import NitroModules
12
+ import ObjectiveC
13
+
14
+ class TrackPlayerCore: NSObject {
15
+ // MARK: - Constants
16
+
17
+ private enum Constants {
18
+ // Time thresholds (in seconds)
19
+ static let skipToPreviousThreshold: Double = 2.0
20
+ static let stateChangeDelay: TimeInterval = 0.1
21
+
22
+ // Duration thresholds for boundary intervals (in seconds)
23
+ static let twoHoursInSeconds: Double = 7200
24
+ static let oneHourInSeconds: Double = 3600
25
+
26
+ // Boundary time intervals (in seconds)
27
+ static let boundaryIntervalLong: Double = 5.0 // For tracks > 2 hours
28
+ static let boundaryIntervalMedium: Double = 2.0 // For tracks > 1 hour
29
+ static let boundaryIntervalDefault: Double = 1.0 // Default interval
30
+
31
+ // UI/Display constants
32
+ static let separatorLineLength: Int = 80
33
+ static let playlistSeparatorLength: Int = 40
34
+ }
35
+
36
+ // MARK: - Properties
37
+
38
+ private var player: AVQueuePlayer?
39
+ private let playlistManager = PlaylistManager.shared
40
+ private var mediaSessionManager: MediaSessionManager?
41
+ private var currentPlaylistId: String?
42
+ private var currentTrackIndex: Int = -1
43
+ private var currentTracks: [TrackItem] = []
44
+ private var isManuallySeeked = false
45
+ private var boundaryTimeObserver: Any?
46
+ private var currentItemObservers: [NSKeyValueObservation] = []
47
+
48
+ var onChangeTrack: ((TrackItem, Reason?) -> Void)?
49
+ var onPlaybackStateChange: ((TrackPlayerState, Reason?) -> Void)?
50
+ var onSeek: ((Double, Double) -> Void)?
51
+ var onPlaybackProgressChange: ((Double, Double, Bool?) -> Void)?
52
+
53
+ static let shared = TrackPlayerCore()
54
+
55
+ // MARK: - Initialization
56
+
57
+ private override init() {
58
+ super.init()
59
+ setupAudioSession()
60
+ setupPlayer()
61
+ mediaSessionManager = MediaSessionManager()
62
+ mediaSessionManager?.setTrackPlayerCore(self)
63
+ }
64
+
65
+ // MARK: - Setup
66
+
67
+ private func setupAudioSession() {
68
+ do {
69
+ let audioSession = AVAudioSession.sharedInstance()
70
+ try audioSession.setCategory(.playback, mode: .default, options: [])
71
+ try audioSession.setActive(true)
72
+ } catch {
73
+ print("โŒ TrackPlayerCore: Failed to setup audio session - \(error)")
74
+ }
75
+ }
76
+
77
+ private func setupPlayer() {
78
+ player = AVQueuePlayer()
79
+ setupPlayerObservers()
80
+ }
81
+
82
+ private func setupPlayerObservers() {
83
+ guard let player = player else { return }
84
+
85
+ // Observe player status
86
+ player.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
87
+ player.addObserver(self, forKeyPath: "rate", options: [.new], context: nil)
88
+
89
+ // Observe time control status
90
+ player.addObserver(self, forKeyPath: "timeControlStatus", options: [.new], context: nil)
91
+
92
+ // Observe current item changes
93
+ NotificationCenter.default.addObserver(
94
+ self,
95
+ selector: #selector(playerItemDidPlayToEndTime),
96
+ name: .AVPlayerItemDidPlayToEndTime,
97
+ object: nil
98
+ )
99
+
100
+ NotificationCenter.default.addObserver(
101
+ self,
102
+ selector: #selector(playerItemFailedToPlayToEndTime),
103
+ name: .AVPlayerItemFailedToPlayToEndTime,
104
+ object: nil
105
+ )
106
+
107
+ // Observe player item errors
108
+ NotificationCenter.default.addObserver(
109
+ self,
110
+ selector: #selector(playerItemNewErrorLogEntry),
111
+ name: .AVPlayerItemNewErrorLogEntry,
112
+ object: nil
113
+ )
114
+
115
+ // Observe time jumps (seeks)
116
+ NotificationCenter.default.addObserver(
117
+ self,
118
+ selector: #selector(playerItemTimeJumped),
119
+ name: .AVPlayerItemTimeJumped,
120
+ object: nil
121
+ )
122
+
123
+ // Observe when item changes (using KVO on currentItem)
124
+ player.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
125
+ }
126
+
127
+ // MARK: - Boundary Time Observer
128
+
129
+ private func setupBoundaryTimeObserver() {
130
+ // Remove existing boundary observer if any
131
+ if let existingObserver = boundaryTimeObserver, let currentPlayer = player {
132
+ currentPlayer.removeTimeObserver(existingObserver)
133
+ boundaryTimeObserver = nil
134
+ }
135
+
136
+ guard let player = player,
137
+ let currentItem = player.currentItem
138
+ else {
139
+ print("โš ๏ธ TrackPlayerCore: Cannot setup boundary observer - no player or item")
140
+ return
141
+ }
142
+
143
+ // Wait for duration to be available
144
+ guard currentItem.status == .readyToPlay else {
145
+ print("โš ๏ธ TrackPlayerCore: Item not ready, will setup boundaries when ready")
146
+ return
147
+ }
148
+
149
+ let duration = currentItem.duration.seconds
150
+ guard duration > 0 && !duration.isNaN && !duration.isInfinite else {
151
+ print("โš ๏ธ TrackPlayerCore: Invalid duration: \(duration), cannot setup boundaries")
152
+ return
153
+ }
154
+
155
+ // Determine interval based on duration
156
+ let interval: Double
157
+ if duration > Constants.twoHoursInSeconds {
158
+ interval = Constants.boundaryIntervalLong
159
+ } else if duration > Constants.oneHourInSeconds {
160
+ interval = Constants.boundaryIntervalMedium
161
+ } else {
162
+ interval = Constants.boundaryIntervalDefault
163
+ }
164
+
165
+ // Create boundary times at each interval
166
+ var boundaryTimes: [NSValue] = []
167
+ var time: Double = 0
168
+ while time <= duration {
169
+ let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
170
+ boundaryTimes.append(NSValue(time: cmTime))
171
+ time += interval
172
+ }
173
+
174
+ print(
175
+ "โฑ๏ธ TrackPlayerCore: Setting up \(boundaryTimes.count) boundary observers (interval: \(interval)s, duration: \(Int(duration))s)"
176
+ )
177
+
178
+ // Add boundary time observer
179
+ boundaryTimeObserver = player.addBoundaryTimeObserver(forTimes: boundaryTimes, queue: .main) {
180
+ [weak self] in
181
+ guard let self = self else { return }
182
+ self.handleBoundaryTimeCrossed()
183
+ }
184
+
185
+ print("โฑ๏ธ TrackPlayerCore: Boundary time observer setup complete")
186
+ }
187
+
188
+ private func handleBoundaryTimeCrossed() {
189
+ guard let player = player,
190
+ let currentItem = player.currentItem
191
+ else { return }
192
+
193
+ // Don't fire progress when paused
194
+ guard player.rate > 0 else { return }
195
+
196
+ let position = currentItem.currentTime().seconds
197
+ let duration = currentItem.duration.seconds
198
+
199
+ guard duration > 0 && !duration.isNaN && !duration.isInfinite else { return }
200
+
201
+ print(
202
+ "โฑ๏ธ TrackPlayerCore: Boundary crossed - position: \(Int(position))s / \(Int(duration))s, callback exists: \(onPlaybackProgressChange != nil)"
203
+ )
204
+
205
+ onPlaybackProgressChange?(
206
+ position,
207
+ duration,
208
+ isManuallySeeked ? true : nil
209
+ )
210
+ isManuallySeeked = false
211
+ }
212
+
213
+ // MARK: - Notification Handlers
214
+
215
+ @objc private func playerItemDidPlayToEndTime(notification: Notification) {
216
+ print("\n๐Ÿ TrackPlayerCore: Track finished playing")
217
+
218
+ guard let finishedItem = notification.object as? AVPlayerItem else {
219
+ print("โš ๏ธ Cannot identify finished item")
220
+ skipToNext()
221
+ return
222
+ }
223
+
224
+ if let trackId = finishedItem.trackId,
225
+ let track = currentTracks.first(where: { $0.id == trackId })
226
+ {
227
+ print("๐Ÿ Finished: \(track.title)")
228
+ }
229
+
230
+ // Check remaining queue
231
+ if let player = player {
232
+ print("๐Ÿ“‹ Remaining items in queue: \(player.items().count)")
233
+ }
234
+
235
+ // Track ended naturally
236
+ onChangeTrack?(
237
+ getCurrentTrack()
238
+ ?? TrackItem(
239
+ id: "",
240
+ title: "",
241
+ artist: "",
242
+ album: "",
243
+ duration: 0,
244
+ url: "",
245
+ artwork: nil
246
+ ), .end)
247
+
248
+ // Try to play next track
249
+ skipToNext()
250
+ }
251
+
252
+ @objc private func playerItemFailedToPlayToEndTime(notification: Notification) {
253
+ if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
254
+ print("โŒ TrackPlayerCore: Playback failed - \(error)")
255
+ onPlaybackStateChange?(.stopped, .error)
256
+ }
257
+ }
258
+
259
+ @objc private func playerItemNewErrorLogEntry(notification: Notification) {
260
+ guard let item = notification.object as? AVPlayerItem,
261
+ let errorLog = item.errorLog()
262
+ else { return }
263
+
264
+ for event in errorLog.events ?? [] {
265
+ print(
266
+ "โŒ TrackPlayerCore: Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)"
267
+ )
268
+ }
269
+
270
+ // Also check item error
271
+ if let error = item.error {
272
+ print("โŒ TrackPlayerCore: Item error - \(error.localizedDescription)")
273
+ }
274
+ }
275
+
276
+ @objc private func playerItemTimeJumped(notification: Notification) {
277
+ guard let player = player,
278
+ let currentItem = player.currentItem
279
+ else { return }
280
+
281
+ let position = currentItem.currentTime().seconds
282
+ let duration = currentItem.duration.seconds
283
+
284
+ print("๐ŸŽฏ TrackPlayerCore: Time jumped (seek detected) - position: \(Int(position))s")
285
+
286
+ // Call onSeek callback immediately
287
+ onSeek?(position, duration)
288
+
289
+ // Mark that this was a manual seek
290
+ isManuallySeeked = true
291
+
292
+ // Trigger immediate progress update
293
+ handleBoundaryTimeCrossed()
294
+ }
295
+
296
+ // MARK: - KVO Observer
297
+
298
+ override func observeValue(
299
+ forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
300
+ context: UnsafeMutableRawPointer?
301
+ ) {
302
+ guard let player = player else { return }
303
+
304
+ print("๐Ÿ‘€ TrackPlayerCore: KVO - keyPath: \(keyPath ?? "nil")")
305
+
306
+ if keyPath == "status" {
307
+ print("๐Ÿ‘€ TrackPlayerCore: Player status changed to: \(player.status.rawValue)")
308
+ if player.status == .readyToPlay {
309
+ emitStateChange()
310
+ } else if player.status == .failed {
311
+ print("โŒ TrackPlayerCore: Player failed")
312
+ onPlaybackStateChange?(.stopped, .error)
313
+ }
314
+ } else if keyPath == "rate" {
315
+ print("๐Ÿ‘€ TrackPlayerCore: Rate changed to: \(player.rate)")
316
+ emitStateChange()
317
+ } else if keyPath == "timeControlStatus" {
318
+ print("๐Ÿ‘€ TrackPlayerCore: TimeControlStatus changed to: \(player.timeControlStatus.rawValue)")
319
+ emitStateChange()
320
+ } else if keyPath == "currentItem" {
321
+ print("๐Ÿ‘€ TrackPlayerCore: Current item changed")
322
+ currentItemDidChange()
323
+ }
324
+ }
325
+
326
+ // MARK: - Item Change Handling
327
+
328
+ @objc private func currentItemDidChange() {
329
+ // Clear old item observers
330
+ currentItemObservers.removeAll()
331
+
332
+ // Track changed - update index
333
+ guard let player = player,
334
+ let currentItem = player.currentItem
335
+ else {
336
+ print("โš ๏ธ TrackPlayerCore: Current item changed to nil")
337
+ return
338
+ }
339
+
340
+ print("\n" + String(repeating: "โ–ถ", count: Constants.separatorLineLength))
341
+ print("๐Ÿ”„ TrackPlayerCore: CURRENT ITEM CHANGED")
342
+ print(String(repeating: "โ–ถ", count: Constants.separatorLineLength))
343
+
344
+ // Log current item details
345
+ if let trackId = currentItem.trackId,
346
+ let track = currentTracks.first(where: { $0.id == trackId })
347
+ {
348
+ print("โ–ถ๏ธ NOW PLAYING: \(track.title) - \(track.artist) (ID: \(track.id))")
349
+ } else {
350
+ print("โš ๏ธ NOW PLAYING: Unknown track (trackId: \(currentItem.trackId ?? "nil"))")
351
+ }
352
+
353
+ // Show remaining items in queue
354
+ let remainingItems = player.items()
355
+ print("\n๐Ÿ“‹ REMAINING ITEMS IN QUEUE: \(remainingItems.count)")
356
+ for (index, item) in remainingItems.enumerated() {
357
+ if let trackId = item.trackId, let track = currentTracks.first(where: { $0.id == trackId }) {
358
+ let marker = item == currentItem ? "โ–ถ๏ธ" : " "
359
+ print("\(marker) [\(index + 1)] \(track.title) - \(track.artist)")
360
+ } else {
361
+ print(" [\(index + 1)] โš ๏ธ Unknown track")
362
+ }
363
+ }
364
+
365
+ print(String(repeating: "โ–ถ", count: Constants.separatorLineLength) + "\n")
366
+
367
+ // Log item status
368
+ print("๐Ÿ“ฑ TrackPlayerCore: Item status: \(currentItem.status.rawValue)")
369
+
370
+ // Check for errors
371
+ if let error = currentItem.error {
372
+ print("โŒ TrackPlayerCore: Current item has error - \(error.localizedDescription)")
373
+ }
374
+
375
+ // Setup KVO observers for current item
376
+ setupCurrentItemObservers(item: currentItem)
377
+
378
+ // Update track index
379
+ if let trackId = currentItem.trackId {
380
+ print("๐Ÿ” TrackPlayerCore: Looking up trackId '\(trackId)' in currentTracks...")
381
+ print(" Current index BEFORE lookup: \(currentTrackIndex)")
382
+
383
+ if let index = currentTracks.firstIndex(where: { $0.id == trackId }) {
384
+ print(" โœ… Found track at index: \(index)")
385
+ print(" Setting currentTrackIndex from \(currentTrackIndex) to \(index)")
386
+
387
+ let oldIndex = currentTrackIndex
388
+ currentTrackIndex = index
389
+
390
+ if let track = currentTracks[safe: index] {
391
+ print(" ๐ŸŽต Track: \(track.title) - \(track.artist)")
392
+
393
+ // Only emit onChangeTrack if index actually changed
394
+ // This prevents duplicate emissions
395
+ if oldIndex != index {
396
+ print(" ๐Ÿ“ข Emitting onChangeTrack (index changed from \(oldIndex) to \(index))")
397
+ onChangeTrack?(track, .skip)
398
+ mediaSessionManager?.onTrackChanged()
399
+ } else {
400
+ print(" โญ๏ธ Skipping onChangeTrack emission (index unchanged)")
401
+ }
402
+ }
403
+ } else {
404
+ print(" โš ๏ธ Track ID '\(trackId)' NOT FOUND in currentTracks!")
405
+ print(" Current tracks:")
406
+ for (idx, track) in currentTracks.enumerated() {
407
+ print(" [\(idx)] \(track.id) - \(track.title)")
408
+ }
409
+ }
410
+ }
411
+
412
+ // Setup boundary observers when item is ready
413
+ if currentItem.status == .readyToPlay {
414
+ setupBoundaryTimeObserver()
415
+ }
416
+ }
417
+
418
+ private func setupCurrentItemObservers(item: AVPlayerItem) {
419
+ print("๐Ÿ“ฑ TrackPlayerCore: Setting up item observers")
420
+
421
+ // Observe status - recreate boundaries when ready
422
+ let statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
423
+ if item.status == .readyToPlay {
424
+ print("โœ… TrackPlayerCore: Item ready, setting up boundaries")
425
+ self?.setupBoundaryTimeObserver()
426
+ } else if item.status == .failed {
427
+ print("โŒ TrackPlayerCore: Item failed")
428
+ self?.onPlaybackStateChange?(.stopped, .error)
429
+ }
430
+ }
431
+ currentItemObservers.append(statusObserver)
432
+
433
+ // Observe playback buffer
434
+ let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { item, _ in
435
+ if item.isPlaybackBufferEmpty {
436
+ print("โธ๏ธ TrackPlayerCore: Buffer empty (buffering)")
437
+ }
438
+ }
439
+ currentItemObservers.append(bufferEmptyObserver)
440
+
441
+ let bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) {
442
+ item, _ in
443
+ if item.isPlaybackLikelyToKeepUp {
444
+ print("โ–ถ๏ธ TrackPlayerCore: Buffer likely to keep up")
445
+ }
446
+ }
447
+ currentItemObservers.append(bufferKeepUpObserver)
448
+ }
449
+
450
+ // MARK: - Playlist Management
451
+
452
+ func loadPlaylist(playlistId: String) {
453
+ DispatchQueue.main.async { [weak self] in
454
+ guard let self = self else { return }
455
+
456
+ print("\n" + String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength))
457
+ print("๐Ÿ“‚ TrackPlayerCore: LOAD PLAYLIST REQUEST")
458
+ print(" Playlist ID: \(playlistId)")
459
+
460
+ let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
461
+ if let playlist = playlist {
462
+ print(" โœ… Found playlist: \(playlist.name)")
463
+ print(" ๐Ÿ“‹ Contains \(playlist.tracks.count) tracks:")
464
+ for (index, track) in playlist.tracks.enumerated() {
465
+ print(" [\(index + 1)] \(track.title) - \(track.artist)")
466
+ }
467
+ print(String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
468
+
469
+ self.currentPlaylistId = playlistId
470
+ self.updatePlayerQueue(tracks: playlist.tracks)
471
+ // Emit initial state (paused/stopped before play)
472
+ self.emitStateChange()
473
+ // Automatically start playback after loading
474
+ self.play()
475
+ } else {
476
+ print(" โŒ Playlist NOT FOUND")
477
+ print(String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
478
+ }
479
+ }
480
+ }
481
+
482
+ func updatePlaylist(playlistId: String) {
483
+ DispatchQueue.main.async { [weak self] in
484
+ guard let self = self else { return }
485
+ if self.currentPlaylistId == playlistId {
486
+ let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
487
+ if let playlist = playlist {
488
+ self.updatePlayerQueue(tracks: playlist.tracks)
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ // MARK: - Public Methods
495
+
496
+ func getCurrentPlaylistId() -> String? {
497
+ return currentPlaylistId
498
+ }
499
+
500
+ func getPlaylistManager() -> PlaylistManager {
501
+ return playlistManager
502
+ }
503
+
504
+ private func emitStateChange(reason: Reason? = nil) {
505
+ guard let player = player else { return }
506
+
507
+ let state: TrackPlayerState
508
+ if player.rate == 0 {
509
+ state = .paused
510
+ } else if player.timeControlStatus == .playing {
511
+ state = .playing
512
+ } else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
513
+ state = .paused // Buffering
514
+ } else {
515
+ state = .stopped
516
+ }
517
+
518
+ print("๐Ÿ”” TrackPlayerCore: Emitting state change: \(state)")
519
+ print("๐Ÿ”” TrackPlayerCore: Callback exists: \(onPlaybackStateChange != nil)")
520
+ onPlaybackStateChange?(state, reason)
521
+ mediaSessionManager?.onPlaybackStateChanged()
522
+ }
523
+
524
+ // MARK: - Queue Management
525
+
526
+ private func updatePlayerQueue(tracks: [TrackItem]) {
527
+ print("\n" + String(repeating: "=", count: Constants.separatorLineLength))
528
+ print("๐Ÿ“‹ TrackPlayerCore: UPDATE PLAYER QUEUE - Received \(tracks.count) tracks")
529
+ print(String(repeating: "=", count: Constants.separatorLineLength))
530
+
531
+ // Print the full playlist being fed
532
+ for (index, track) in tracks.enumerated() {
533
+ print(" [\(index + 1)] ๐ŸŽต \(track.title) - \(track.artist) (ID: \(track.id))")
534
+ }
535
+ print(String(repeating: "=", count: Constants.separatorLineLength) + "\n")
536
+
537
+ // Store tracks for index tracking
538
+ currentTracks = tracks
539
+ currentTrackIndex = 0
540
+ print("๐Ÿ”ข TrackPlayerCore: Reset currentTrackIndex to 0 (will be updated by KVO observer)")
541
+
542
+ // Remove old boundary observer if exists (this is safe)
543
+ if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
544
+ currentPlayer.removeTimeObserver(boundaryObserver)
545
+ boundaryTimeObserver = nil
546
+ }
547
+
548
+ // Create AVPlayerItems from tracks
549
+ let items = tracks.compactMap { track -> AVPlayerItem? in
550
+ guard let url = URL(string: track.url) else {
551
+ print("โŒ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
552
+ return nil
553
+ }
554
+
555
+ let item = AVPlayerItem(url: url)
556
+
557
+ // Set metadata using AVMutableMetadataItem
558
+ let metadata = AVMutableMetadataItem()
559
+ metadata.identifier = .commonIdentifierTitle
560
+ metadata.value = track.title as NSString
561
+ metadata.locale = Locale.current
562
+
563
+ let artistMetadata = AVMutableMetadataItem()
564
+ artistMetadata.identifier = .commonIdentifierArtist
565
+ artistMetadata.value = track.artist as NSString
566
+ artistMetadata.locale = Locale.current
567
+
568
+ let albumMetadata = AVMutableMetadataItem()
569
+ albumMetadata.identifier = .commonIdentifierAlbumName
570
+ albumMetadata.value = track.album as NSString
571
+ albumMetadata.locale = Locale.current
572
+
573
+ // Note: AVPlayerItem doesn't have externalMetadata property
574
+ // Metadata will be set via MPNowPlayingInfoCenter in MediaSessionManager
575
+
576
+ // Store track ID in item for later reference
577
+ item.trackId = track.id
578
+
579
+ return item
580
+ }
581
+
582
+ guard !items.isEmpty else {
583
+ print("โŒ TrackPlayerCore: No valid items to play")
584
+ return
585
+ }
586
+
587
+ // Replace current queue (player should always exist after setupPlayer)
588
+ guard let existingPlayer = self.player else {
589
+ print("โŒ TrackPlayerCore: No player available - this should never happen!")
590
+ return
591
+ }
592
+
593
+ print(
594
+ "๐Ÿ”„ TrackPlayerCore: Updating queue - removing \(existingPlayer.items().count) items, adding \(items.count) new items"
595
+ )
596
+
597
+ // Remove all existing items
598
+ existingPlayer.removeAllItems()
599
+
600
+ // Add new items IN ORDER
601
+ // IMPORTANT: insert(after: nil) puts item at the start
602
+ // To maintain order, we need to track the last inserted item
603
+ var lastItem: AVPlayerItem? = nil
604
+ for (index, item) in items.enumerated() {
605
+ existingPlayer.insert(item, after: lastItem)
606
+ lastItem = item
607
+
608
+ if let trackId = item.trackId, let track = tracks.first(where: { $0.id == trackId }) {
609
+ print(" โž• Added to player queue [\(index + 1)]: \(track.title)")
610
+ }
611
+ }
612
+
613
+ // Verify what's actually in the player now
614
+ print(
615
+ "\n๐Ÿ” TrackPlayerCore: VERIFICATION - Player now has \(existingPlayer.items().count) items:")
616
+ for (index, item) in existingPlayer.items().enumerated() {
617
+ if let trackId = item.trackId, let track = tracks.first(where: { $0.id == trackId }) {
618
+ print(" [\(index + 1)] โœ“ \(track.title) - \(track.artist) (ID: \(track.id))")
619
+ } else {
620
+ print(" [\(index + 1)] โš ๏ธ Unknown item (no trackId)")
621
+ }
622
+ }
623
+
624
+ if let currentItem = existingPlayer.currentItem, let trackId = currentItem.trackId {
625
+ if let track = tracks.first(where: { $0.id == trackId }) {
626
+ print("โ–ถ๏ธ Current item: \(track.title)")
627
+ }
628
+ }
629
+ print(String(repeating: "=", count: Constants.separatorLineLength) + "\n")
630
+
631
+ // Note: Boundary time observers will be set up automatically when item becomes ready
632
+ // This happens in setupCurrentItemObservers() -> status observer -> setupBoundaryTimeObserver()
633
+
634
+ // Notify track change
635
+ if let firstTrack = tracks.first {
636
+ print("๐ŸŽต TrackPlayerCore: Emitting track change: \(firstTrack.title)")
637
+ print("๐ŸŽต TrackPlayerCore: onChangeTrack callback exists: \(onChangeTrack != nil)")
638
+ onChangeTrack?(firstTrack, nil)
639
+ mediaSessionManager?.onTrackChanged()
640
+ }
641
+
642
+ print("โœ… TrackPlayerCore: Queue updated with \(items.count) tracks")
643
+ }
644
+
645
+ func getCurrentTrack() -> TrackItem? {
646
+ guard currentTrackIndex >= 0 && currentTrackIndex < currentTracks.count else {
647
+ return nil
648
+ }
649
+ return currentTracks[currentTrackIndex]
650
+ }
651
+
652
+ func play() {
653
+ print("โ–ถ๏ธ TrackPlayerCore: play() called")
654
+ DispatchQueue.main.async { [weak self] in
655
+ guard let self = self else { return }
656
+ print("โ–ถ๏ธ TrackPlayerCore: Calling player.play()")
657
+ if let player = self.player {
658
+ print("โ–ถ๏ธ TrackPlayerCore: Player status: \(player.status.rawValue)")
659
+ if let currentItem = player.currentItem {
660
+ print("โ–ถ๏ธ TrackPlayerCore: Current item status: \(currentItem.status.rawValue)")
661
+ if let error = currentItem.error {
662
+ print("โŒ TrackPlayerCore: Current item error: \(error.localizedDescription)")
663
+ }
664
+ }
665
+ player.play()
666
+ // Emit state change immediately for responsive UI
667
+ // KVO will also fire, but this ensures immediate feedback
668
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.stateChangeDelay) {
669
+ [weak self] in
670
+ self?.emitStateChange()
671
+ }
672
+ } else {
673
+ print("โŒ TrackPlayerCore: No player available")
674
+ }
675
+ }
676
+ }
677
+
678
+ func pause() {
679
+ print("โธ๏ธ TrackPlayerCore: pause() called")
680
+ DispatchQueue.main.async { [weak self] in
681
+ guard let self = self else { return }
682
+ self.player?.pause()
683
+ // Emit state change immediately for responsive UI
684
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.stateChangeDelay) { [weak self] in
685
+ self?.emitStateChange()
686
+ }
687
+ }
688
+ }
689
+
690
+ func playSong(songId: String, fromPlaylist: String?) {
691
+ print(
692
+ "๐ŸŽต TrackPlayerCore: playSong() called - songId: \(songId), fromPlaylist: \(fromPlaylist ?? "nil")"
693
+ )
694
+
695
+ DispatchQueue.main.async { [weak self] in
696
+ guard let self = self else { return }
697
+
698
+ var targetPlaylistId: String?
699
+ var songIndex: Int = -1
700
+
701
+ // Case 1: If fromPlaylist is provided, use that playlist
702
+ if let playlistId = fromPlaylist {
703
+ print("๐ŸŽต TrackPlayerCore: Looking for song in specified playlist: \(playlistId)")
704
+ if let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) {
705
+ if let index = playlist.tracks.firstIndex(where: { $0.id == songId }) {
706
+ targetPlaylistId = playlistId
707
+ songIndex = index
708
+ print("โœ… Found song at index \(index) in playlist \(playlistId)")
709
+ } else {
710
+ print("โš ๏ธ Song \(songId) not found in specified playlist \(playlistId)")
711
+ return
712
+ }
713
+ } else {
714
+ print("โš ๏ธ Playlist \(playlistId) not found")
715
+ return
716
+ }
717
+ }
718
+ // Case 2: If fromPlaylist is not provided, search in current/loaded playlist first
719
+ else {
720
+ print("๐ŸŽต TrackPlayerCore: No playlist specified, checking current playlist")
721
+
722
+ // Check if song exists in currently loaded playlist
723
+ if let currentId = self.currentPlaylistId,
724
+ let currentPlaylist = self.playlistManager.getPlaylist(playlistId: currentId)
725
+ {
726
+ if let index = currentPlaylist.tracks.firstIndex(where: { $0.id == songId }) {
727
+ targetPlaylistId = currentId
728
+ songIndex = index
729
+ print("โœ… Found song at index \(index) in current playlist \(currentId)")
730
+ }
731
+ }
732
+
733
+ // If not found in current playlist, search in all playlists
734
+ if songIndex == -1 {
735
+ print("๐Ÿ” Song not found in current playlist, searching all playlists...")
736
+ let allPlaylists = self.playlistManager.getAllPlaylists()
737
+
738
+ for playlist in allPlaylists {
739
+ if let index = playlist.tracks.firstIndex(where: { $0.id == songId }) {
740
+ targetPlaylistId = playlist.id
741
+ songIndex = index
742
+ print("โœ… Found song at index \(index) in playlist \(playlist.id)")
743
+ break
744
+ }
745
+ }
746
+
747
+ // If still not found, just use the first playlist if available
748
+ if songIndex == -1 && !allPlaylists.isEmpty {
749
+ targetPlaylistId = allPlaylists[0].id
750
+ songIndex = 0
751
+ print("โš ๏ธ Song not found in any playlist, using first playlist and starting at index 0")
752
+ }
753
+ }
754
+ }
755
+
756
+ // Now play the song
757
+ guard let playlistId = targetPlaylistId, songIndex >= 0 else {
758
+ print("โŒ Could not determine playlist or song index")
759
+ return
760
+ }
761
+
762
+ // Load playlist if it's different from current
763
+ if self.currentPlaylistId != playlistId {
764
+ print("๐Ÿ”„ Loading new playlist: \(playlistId)")
765
+ if let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) {
766
+ self.currentPlaylistId = playlistId
767
+ self.updatePlayerQueue(tracks: playlist.tracks)
768
+ }
769
+ }
770
+
771
+ // Play from the found index
772
+ print("โ–ถ๏ธ Playing from index: \(songIndex)")
773
+ self.playFromIndex(index: songIndex)
774
+ }
775
+ }
776
+
777
+ func skipToNext() {
778
+ DispatchQueue.main.async { [weak self] in
779
+ guard let self = self,
780
+ let queuePlayer = self.player
781
+ else {
782
+ return
783
+ }
784
+
785
+ print("\nโญ๏ธ TrackPlayerCore: SKIP TO NEXT")
786
+ print(" BEFORE:")
787
+ print(" currentTrackIndex: \(self.currentTrackIndex)")
788
+ print(" Total tracks in currentTracks: \(self.currentTracks.count)")
789
+ print(" Items in player queue: \(queuePlayer.items().count)")
790
+
791
+ if let currentItem = queuePlayer.currentItem, let trackId = currentItem.trackId {
792
+ if let track = self.currentTracks.first(where: { $0.id == trackId }) {
793
+ print(" Currently playing: \(track.title) (ID: \(track.id))")
794
+ }
795
+ }
796
+
797
+ // Check if there are more items in the queue
798
+ if self.currentTrackIndex + 1 < self.currentTracks.count {
799
+ print(" ๐Ÿ”„ Calling advanceToNextItem()...")
800
+ queuePlayer.advanceToNextItem()
801
+
802
+ // NOTE: Don't manually update currentTrackIndex here!
803
+ // The KVO observer (currentItemDidChange) will update it automatically
804
+
805
+ print(" AFTER advanceToNextItem():")
806
+ print(" Items in player queue: \(queuePlayer.items().count)")
807
+
808
+ if let newCurrentItem = queuePlayer.currentItem, let trackId = newCurrentItem.trackId {
809
+ if let track = self.currentTracks.first(where: { $0.id == trackId }) {
810
+ print(" New current item: \(track.title) (ID: \(track.id))")
811
+ }
812
+ }
813
+
814
+ print(" โณ Waiting for KVO observer to update index...")
815
+ } else {
816
+ print(" โš ๏ธ No more tracks in playlist")
817
+ // At end of playlist - stop or loop
818
+ queuePlayer.pause()
819
+ self.onPlaybackStateChange?(.stopped, .end)
820
+ }
821
+ }
822
+ }
823
+
824
+ func skipToPrevious() {
825
+ DispatchQueue.main.async { [weak self] in
826
+ guard let self = self,
827
+ let queuePlayer = self.player
828
+ else {
829
+ return
830
+ }
831
+
832
+ print("\nโฎ๏ธ TrackPlayerCore: SKIP TO PREVIOUS")
833
+ print(" Current index: \(self.currentTrackIndex)")
834
+ print(" Current time: \(queuePlayer.currentTime().seconds)s")
835
+
836
+ let currentTime = queuePlayer.currentTime()
837
+ if currentTime.seconds > Constants.skipToPreviousThreshold {
838
+ // If more than threshold seconds in, restart current track
839
+ print(
840
+ " ๐Ÿ”„ More than \(Int(Constants.skipToPreviousThreshold))s in, restarting current track")
841
+ queuePlayer.seek(to: .zero)
842
+ } else if self.currentTrackIndex > 0 {
843
+ // Go to previous track
844
+ let previousIndex = self.currentTrackIndex - 1
845
+ print(" โฎ๏ธ Going to previous track at index \(previousIndex)")
846
+ self.playFromIndex(index: previousIndex)
847
+ } else {
848
+ // Already at first track, restart it
849
+ print(" ๐Ÿ”„ Already at first track, restarting it")
850
+ queuePlayer.seek(to: .zero)
851
+ }
852
+ }
853
+ }
854
+
855
+ func seek(position: Double) {
856
+ DispatchQueue.main.async { [weak self] in
857
+ guard let self = self, let player = self.player else { return }
858
+
859
+ self.isManuallySeeked = true
860
+ let time = CMTime(seconds: position, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
861
+ player.seek(to: time) { [weak self] completed in
862
+ if completed {
863
+ let duration = player.currentItem?.duration.seconds ?? 0.0
864
+ self?.onSeek?(position, duration)
865
+ }
866
+ }
867
+ }
868
+ }
869
+
870
+ // MARK: - State Management
871
+
872
+ func getState() -> PlayerState {
873
+ guard let player = player else {
874
+ return PlayerState(
875
+ currentTrack: nil,
876
+ currentPosition: 0.0,
877
+ totalDuration: 0.0,
878
+ currentState: .stopped,
879
+ currentPlaylistId: currentPlaylistId.map { Variant_NullType_String.second($0) },
880
+ currentIndex: -1.0
881
+ )
882
+ }
883
+
884
+ let currentTrack = getCurrentTrack()
885
+ let currentPosition = player.currentTime().seconds
886
+ let totalDuration = player.currentItem?.duration.seconds ?? 0.0
887
+
888
+ let currentState: TrackPlayerState
889
+ if player.rate == 0 {
890
+ currentState = .paused
891
+ } else if player.timeControlStatus == .playing {
892
+ currentState = .playing
893
+ } else {
894
+ currentState = .stopped
895
+ }
896
+
897
+ // Get current index
898
+ let currentIndex: Double = currentTrackIndex >= 0 ? Double(currentTrackIndex) : -1.0
899
+
900
+ return PlayerState(
901
+ currentTrack: currentTrack.map { Variant_NullType_TrackItem.second($0) },
902
+ currentPosition: currentPosition,
903
+ totalDuration: totalDuration,
904
+ currentState: currentState,
905
+ currentPlaylistId: currentPlaylistId.map { Variant_NullType_String.second($0) },
906
+ currentIndex: currentIndex
907
+ )
908
+ }
909
+
910
+ func configure(
911
+ androidAutoEnabled: Bool?,
912
+ carPlayEnabled: Bool?,
913
+ showInNotification: Bool?
914
+ ) {
915
+ DispatchQueue.main.async { [weak self] in
916
+ self?.mediaSessionManager?.configure(
917
+ androidAutoEnabled: androidAutoEnabled,
918
+ carPlayEnabled: carPlayEnabled,
919
+ showInNotification: showInNotification
920
+ )
921
+ }
922
+ }
923
+
924
+ func getAllPlaylists() -> [Playlist] {
925
+ return playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
926
+ }
927
+
928
+ func playFromIndex(index: Int) {
929
+ DispatchQueue.main.async { [weak self] in
930
+ guard let self = self,
931
+ index >= 0 && index < self.currentTracks.count
932
+ else {
933
+ print("โŒ TrackPlayerCore: playFromIndex - invalid index \(index)")
934
+ return
935
+ }
936
+
937
+ print("\n๐ŸŽฏ TrackPlayerCore: PLAY FROM INDEX \(index)")
938
+ print(" Total tracks in playlist: \(self.currentTracks.count)")
939
+ print(" Current index: \(self.currentTrackIndex), target index: \(index)")
940
+
941
+ // Store the full playlist
942
+ let fullPlaylist = self.currentTracks
943
+
944
+ // Update currentTrackIndex BEFORE updating queue
945
+ self.currentTrackIndex = index
946
+
947
+ // Recreate the queue starting from the target index
948
+ // This ensures all remaining tracks are in the queue
949
+ let tracksToPlay = Array(fullPlaylist[index...])
950
+ print(" ๐Ÿ”„ Creating queue with \(tracksToPlay.count) tracks starting from index \(index)")
951
+
952
+ // Update the queue (but keep the full currentTracks for reference)
953
+ let items = tracksToPlay.compactMap { track -> AVPlayerItem? in
954
+ guard let url = URL(string: track.url) else { return nil }
955
+ let item = AVPlayerItem(url: url)
956
+ item.trackId = track.id
957
+ return item
958
+ }
959
+
960
+ guard let player = self.player, !items.isEmpty else {
961
+ print("โŒ No player or no items to play")
962
+ return
963
+ }
964
+
965
+ // Remove old boundary observer
966
+ if let boundaryObserver = self.boundaryTimeObserver {
967
+ player.removeTimeObserver(boundaryObserver)
968
+ self.boundaryTimeObserver = nil
969
+ }
970
+
971
+ // Clear and rebuild queue
972
+ player.removeAllItems()
973
+ var lastItem: AVPlayerItem? = nil
974
+ for item in items {
975
+ player.insert(item, after: lastItem)
976
+ lastItem = item
977
+ }
978
+
979
+ // Restore the full playlist reference (don't slice it!)
980
+ self.currentTracks = fullPlaylist
981
+
982
+ print(" โœ… Queue recreated. Now at index: \(self.currentTrackIndex)")
983
+ if let track = self.getCurrentTrack() {
984
+ print(" ๐ŸŽต Playing: \(track.title)")
985
+ self.onChangeTrack?(track, .skip)
986
+ self.mediaSessionManager?.onTrackChanged()
987
+ }
988
+
989
+ player.play()
990
+ }
991
+ }
992
+
993
+ // MARK: - Cleanup
994
+
995
+ deinit {
996
+ print("๐Ÿงน TrackPlayerCore: Cleaning up...")
997
+
998
+ // Remove boundary time observer
999
+ if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
1000
+ currentPlayer.removeTimeObserver(boundaryObserver)
1001
+ }
1002
+
1003
+ // Clear item observers (modern KVO automatically releases)
1004
+ currentItemObservers.removeAll()
1005
+
1006
+ // Remove player KVO observers (these were added in setupPlayer)
1007
+ if let currentPlayer = player {
1008
+ currentPlayer.removeObserver(self, forKeyPath: "status")
1009
+ currentPlayer.removeObserver(self, forKeyPath: "rate")
1010
+ currentPlayer.removeObserver(self, forKeyPath: "timeControlStatus")
1011
+ currentPlayer.removeObserver(self, forKeyPath: "currentItem")
1012
+ print("โœ… TrackPlayerCore: Player observers removed")
1013
+ }
1014
+
1015
+ // Remove all notification observers
1016
+ NotificationCenter.default.removeObserver(self)
1017
+ print("โœ… TrackPlayerCore: Cleanup complete")
1018
+ }
1019
+ }
1020
+
1021
+ // Safe array access extension
1022
+ extension Array {
1023
+ subscript(safe index: Int) -> Element? {
1024
+ return indices.contains(index) ? self[index] : nil
1025
+ }
1026
+ }
1027
+
1028
+ // Associated object extension for AVPlayerItem
1029
+ private var trackIdKey: UInt8 = 0
1030
+
1031
+ extension AVPlayerItem {
1032
+ var trackId: String? {
1033
+ get {
1034
+ return objc_getAssociatedObject(self, &trackIdKey) as? String
1035
+ }
1036
+ set {
1037
+ objc_setAssociatedObject(self, &trackIdKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
1038
+ }
1039
+ }
1040
+ }