react-native-nitro-player 0.7.0 → 0.7.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +165 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +161 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +11 -58
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
  23. package/ios/HybridDownloadManager.swift +32 -26
  24. package/ios/HybridEqualizer.swift +48 -35
  25. package/ios/HybridTrackPlayer.swift +127 -102
  26. package/ios/core/ListenerRegistry.swift +60 -0
  27. package/ios/core/TrackPlayerCore.swift +130 -2356
  28. package/ios/core/TrackPlayerListener.swift +395 -0
  29. package/ios/core/TrackPlayerNotify.swift +52 -0
  30. package/ios/core/TrackPlayerPlayback.swift +274 -0
  31. package/ios/core/TrackPlayerQueue.swift +212 -0
  32. package/ios/core/TrackPlayerQueueBuild.swift +482 -0
  33. package/ios/core/TrackPlayerTempQueue.swift +167 -0
  34. package/ios/core/TrackPlayerUrlLoader.swift +169 -0
  35. package/ios/equalizer/EqualizerCore.swift +24 -89
  36. package/ios/media/MediaSessionManager.swift +32 -49
  37. package/ios/playlist/PlaylistManager.swift +2 -9
  38. package/ios/queue/HybridPlayerQueue.swift +69 -66
  39. package/lib/hooks/useDownloadedTracks.js +16 -13
  40. package/lib/hooks/useEqualizer.d.ts +4 -4
  41. package/lib/hooks/useEqualizer.js +12 -12
  42. package/lib/hooks/useEqualizerPresets.d.ts +3 -3
  43. package/lib/hooks/useEqualizerPresets.js +12 -18
  44. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
  45. package/lib/specs/AudioDevices.nitro.d.ts +2 -2
  46. package/lib/specs/DownloadManager.nitro.d.ts +10 -10
  47. package/lib/specs/Equalizer.nitro.d.ts +9 -9
  48. package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
  49. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  50. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
  51. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +31 -6
  52. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +2 -2
  53. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +16 -3
  54. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +1 -1
  55. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +154 -44
  56. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +10 -10
  57. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +130 -34
  58. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +9 -9
  59. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +115 -24
  60. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +8 -8
  61. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +243 -24
  62. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +16 -8
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +3 -2
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +2 -1
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +10 -10
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +10 -9
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +9 -8
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +45 -8
  70. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
  71. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
  72. package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
  73. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
  74. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
  75. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
  76. package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
  77. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
  78. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
  79. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
  80. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
  85. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
  86. package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
  87. package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
  88. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
  89. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
  90. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
  91. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
  92. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
  93. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
  94. package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
  95. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
  96. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
  97. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
  98. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
  99. package/package.json +1 -1
  100. package/src/hooks/useDownloadedTracks.ts +17 -13
  101. package/src/hooks/useEqualizer.ts +16 -16
  102. package/src/hooks/useEqualizerPresets.ts +15 -21
  103. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
  104. package/src/specs/AudioDevices.nitro.ts +2 -2
  105. package/src/specs/DownloadManager.nitro.ts +10 -10
  106. package/src/specs/Equalizer.nitro.ts +9 -9
  107. package/src/specs/TrackPlayer.nitro.ts +52 -16
@@ -1,127 +1,112 @@
1
- @file:Suppress("ktlint:standard:max-line-length", "ktlint:standard:if-else-wrapping")
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
2
 
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import android.content.Context
6
- import android.net.Uri
7
- import androidx.media3.common.AudioAttributes
8
- import androidx.media3.common.C
9
- import androidx.media3.common.MediaItem
10
- import androidx.media3.common.MediaMetadata
11
- import androidx.media3.common.Player
12
- import androidx.media3.exoplayer.DefaultLoadControl
13
- import androidx.media3.exoplayer.ExoPlayer
14
- import com.margelo.nitro.core.NullType
15
- import com.margelo.nitro.nitroplayer.CurrentPlayingType
16
- import com.margelo.nitro.nitroplayer.NitroPlayerPackage
17
- import com.margelo.nitro.nitroplayer.PlayerState
6
+ import android.os.Handler
7
+ import android.os.HandlerThread
8
+ import android.os.Looper
18
9
  import com.margelo.nitro.nitroplayer.Reason
19
10
  import com.margelo.nitro.nitroplayer.RepeatMode
20
11
  import com.margelo.nitro.nitroplayer.TrackItem
21
12
  import com.margelo.nitro.nitroplayer.TrackPlayerState
22
- import com.margelo.nitro.nitroplayer.Variant_NullType_String
23
- import com.margelo.nitro.nitroplayer.Variant_NullType_TrackItem
24
13
  import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
25
14
  import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
26
- import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
27
- import com.margelo.nitro.nitroplayer.media.MediaLibrary
28
15
  import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
29
- import com.margelo.nitro.nitroplayer.media.MediaLibraryParser
30
16
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
31
- import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
32
17
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
33
- import java.lang.ref.WeakReference
34
- import java.util.Collections
35
- import java.util.concurrent.CountDownLatch
36
- import java.util.concurrent.TimeUnit
37
-
38
- class TrackPlayerCore private constructor(
39
- private val context: Context,
40
- ) {
41
- private val handler = android.os.Handler(android.os.Looper.getMainLooper())
42
- private lateinit var player: ExoPlayer
43
- private val playlistManager = PlaylistManager.getInstance(context)
44
-
45
- // Named Runnable so handler.removeCallbacks() can coalesce rapid playlist
46
- // mutations (e.g. N individual removes followed by a batch add during shuffle)
47
- // into a single player update, preventing audio gaps on Android.
48
- private val updateCurrentPlaylistRunnable = Runnable {
49
- val playlistId = currentPlaylistId ?: return@Runnable
50
- val playlist = playlistManager.getPlaylist(playlistId) ?: return@Runnable
51
-
52
- // Always update the canonical track list first.
18
+ import kotlinx.coroutines.CoroutineScope
19
+ import kotlinx.coroutines.SupervisorJob
20
+ import kotlinx.coroutines.cancel
21
+ import kotlinx.coroutines.suspendCancellableCoroutine
22
+ import kotlin.coroutines.resume
23
+ import kotlin.coroutines.resumeWithException
24
+
25
+ class TrackPlayerCore private constructor(internal val context: Context) {
26
+
27
+ // ── Thread infrastructure ──────────────────────────────────────────────
28
+ /** Main-looper handler only for Android Auto connection callbacks */
29
+ internal val handler = Handler(Looper.getMainLooper())
30
+ internal val playerThread = HandlerThread("NitroPlayer").apply { start() }
31
+ internal val playerHandler = Handler(playerThread.looper)
32
+ internal val scope = CoroutineScope(SupervisorJob())
33
+
34
+ // ── ExoPlayer wrapper (created on player thread inside initExoAndMedia) ──
35
+ internal lateinit var exo: ExoPlayerCore
36
+ /** Safe initialized check — backing field can only be read from the declaring class. */
37
+ internal val isExoInitialized: Boolean get() = ::exo.isInitialized
38
+
39
+ // ── Managers ───────────────────────────────────────────────────────────
40
+ internal val playlistManager = PlaylistManager.getInstance(context)
41
+ internal val downloadManager = DownloadManagerCore.getInstance(context)
42
+ internal val mediaLibraryManager = MediaLibraryManager.getInstance(context)
43
+ internal var mediaSessionManager: MediaSessionManager? = null
44
+
45
+ // ── Playback state ─────────────────────────────────────────────────────
46
+ @Volatile internal var currentPlaylistId: String? = null
47
+ internal var isManuallySeeked = false
48
+ @Volatile internal var isAndroidAutoConnectedField: Boolean = false
49
+ internal var androidAutoConnectionDetector: AndroidAutoConnectionDetector? = null
50
+ internal var previousMediaItem: androidx.media3.common.MediaItem? = null
51
+ @Volatile internal var currentRepeatMode: RepeatMode = RepeatMode.OFF
52
+ internal var lookaheadCount: Int = 5
53
+ internal var playerListener: androidx.media3.common.Player.Listener? = null
54
+
55
+ // ── Temporary queue ────────────────────────────────────────────────────
56
+ internal var playNextStack: MutableList<TrackItem> = mutableListOf()
57
+ internal var upNextQueue: MutableList<TrackItem> = mutableListOf()
58
+ internal var currentTemporaryType: TemporaryType = TemporaryType.NONE
59
+ internal var currentTracks: List<TrackItem> = emptyList()
60
+ internal var currentTrackIndex: Int = -1
61
+
62
+ internal enum class TemporaryType { NONE, PLAY_NEXT, UP_NEXT }
63
+
64
+ // ── Listener registries ────────────────────────────────────────────────
65
+ internal val onChangeTrackListeners =
66
+ ListenerRegistry<(TrackItem, Reason?) -> Unit>()
67
+ internal val onPlaybackStateChangeListeners =
68
+ ListenerRegistry<(TrackPlayerState, Reason?) -> Unit>()
69
+ internal val onSeekListeners =
70
+ ListenerRegistry<(Double, Double) -> Unit>()
71
+ internal val onProgressListeners =
72
+ ListenerRegistry<(Double, Double, Boolean?) -> Unit>()
73
+ internal val onTracksNeedUpdateListeners =
74
+ ListenerRegistry<(List<TrackItem>, Int) -> Unit>()
75
+ internal val onTemporaryQueueChangeListeners =
76
+ ListenerRegistry<(List<TrackItem>, List<TrackItem>) -> Unit>()
77
+ internal val onAndroidAutoConnectionListeners =
78
+ ListenerRegistry<(Boolean) -> Unit>()
79
+
80
+ // ── Progress & playlist-update runnables ───────────────────────────────
81
+ internal val progressUpdateRunnable = object : Runnable {
82
+ override fun run() {
83
+ if (::exo.isInitialized &&
84
+ exo.playbackState != androidx.media3.common.Player.STATE_IDLE
85
+ ) {
86
+ val pos = exo.currentPosition / 1000.0
87
+ val dur = if (exo.duration > 0) exo.duration / 1000.0 else 0.0
88
+ notifyPlaybackProgress(pos, dur, if (isManuallySeeked) true else null)
89
+ isManuallySeeked = false
90
+ }
91
+ playerHandler.postDelayed(this, 250)
92
+ }
93
+ }
94
+
95
+ internal val updateCurrentPlaylistRunnable = Runnable {
96
+ val id = currentPlaylistId ?: return@Runnable
97
+ val playlist = playlistManager.getPlaylist(id) ?: return@Runnable
53
98
  currentTracks = playlist.tracks
54
-
55
- if (::player.isInitialized && player.currentMediaItem != null && player.currentMediaItemIndex >= 0) {
56
- // Something is actively playing — rebuild only the items AFTER the
57
- // current position using surgical removeMediaItems/addMediaItems.
58
- // This avoids setMediaItems() which replaces the entire ExoPlayer
59
- // queue (including the current item) and causes an audible gap.
99
+ if (::exo.isInitialized &&
100
+ exo.currentMediaItem != null &&
101
+ exo.currentMediaItemIndex >= 0
102
+ ) {
60
103
  rebuildQueueFromCurrentPosition()
61
104
  } else {
62
- // Nothing playing yet — safe to do a full replace.
63
105
  updatePlayerQueue(playlist.tracks)
64
106
  }
65
107
  }
66
- private val downloadManager = DownloadManagerCore.getInstance(context)
67
- private val mediaLibraryManager = MediaLibraryManager.getInstance(context)
68
- private var mediaSessionManager: MediaSessionManager? = null
69
- @Volatile private var currentPlaylistId: String? = null
70
- private var isManuallySeeked = false
71
- @Volatile private var isAndroidAutoConnected: Boolean = false
72
- private var androidAutoConnectionDetector: AndroidAutoConnectionDetector? = null
73
- var onAndroidAutoConnectionChange: ((Boolean) -> Unit)? = null
74
- private var previousMediaItem: MediaItem? = null
75
-
76
- private val progressUpdateRunnable =
77
- object : Runnable {
78
- override fun run() {
79
- if (::player.isInitialized && player.playbackState != Player.STATE_IDLE) {
80
- val position = player.currentPosition / 1000.0
81
- val duration = if (player.duration > 0) player.duration / 1000.0 else 0.0
82
- notifyPlaybackProgress(position, duration, if (isManuallySeeked) true else null)
83
- isManuallySeeked = false
84
- }
85
- handler.postDelayed(this, 250) // Update every 250ms
86
- }
87
- }
88
-
89
- // Weak callback wrapper for auto-cleanup
90
- private data class WeakCallbackBox<T>(
91
- private val ownerRef: WeakReference<Any>,
92
- val callback: T,
93
- ) {
94
- val isAlive: Boolean get() = ownerRef.get() != null
95
- }
96
-
97
- // Event listeners - support multiple listeners with auto-cleanup
98
- private val onChangeTrackListeners =
99
- Collections.synchronizedList(mutableListOf<WeakCallbackBox<(TrackItem, Reason?) -> Unit>>())
100
- private val onPlaybackStateChangeListeners =
101
- Collections.synchronizedList(mutableListOf<WeakCallbackBox<(TrackPlayerState, Reason?) -> Unit>>())
102
- private val onSeekListeners =
103
- Collections.synchronizedList(mutableListOf<WeakCallbackBox<(Double, Double) -> Unit>>())
104
- private val onPlaybackProgressChangeListeners =
105
- Collections.synchronizedList(mutableListOf<WeakCallbackBox<(Double, Double, Boolean?) -> Unit>>())
106
-
107
- @Volatile private var currentRepeatMode: RepeatMode = RepeatMode.OFF
108
- private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
109
- private var playerListener: Player.Listener? = null
110
-
111
- // Temporary tracks for addToUpNext and playNext
112
- private var playNextStack: MutableList<TrackItem> = mutableListOf() // LIFO - last added plays first
113
- private var upNextQueue: MutableList<TrackItem> = mutableListOf() // FIFO - first added plays first
114
- private var currentTemporaryType: TemporaryType = TemporaryType.NONE
115
- private var currentTracks: List<TrackItem> = emptyList()
116
- private var currentTrackIndex: Int = -1 // Index in the original playlist (currentTracks)
117
-
118
- // Enum to track what type of track is currently playing
119
- private enum class TemporaryType {
120
- NONE, // Playing from original playlist
121
- PLAY_NEXT, // Currently in playNextStack
122
- UP_NEXT, // Currently in upNextQueue
123
- }
124
108
 
109
+ // ── Singleton ──────────────────────────────────────────────────────────
125
110
  companion object {
126
111
  @Volatile
127
112
  @Suppress("ktlint:standard:property-naming")
@@ -134,1802 +119,83 @@ class TrackPlayerCore private constructor(
134
119
  }
135
120
 
136
121
  init {
137
- // Run synchronously on main thread to avoid deadlock
138
- // when awaitInitialization is called from main thread
139
- val initRunnable =
140
- Runnable {
141
- // ============================================================
142
- // GAPLESS PLAYBACK CONFIGURATION
143
- // ============================================================
144
- // Configure LoadControl for maximum gapless playback
145
- // Large buffers ensure next track is fully ready before current ends
146
- val loadControl =
147
- DefaultLoadControl
148
- .Builder()
149
- .setBufferDurationsMs(
150
- 30_000, // MIN_BUFFER_MS: 30 seconds minimum buffer
151
- 120_000, // MAX_BUFFER_MS: 2 minutes maximum buffer (enables preloading next tracks)
152
- 2_500, // BUFFER_FOR_PLAYBACK_MS: 2.5s before playback starts
153
- 5_000, // BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS: 5s after rebuffer
154
- ).setBackBuffer(30_000, true) // Keep 30s back buffer for seamless seek-back
155
- .setTargetBufferBytes(C.LENGTH_UNSET) // No size limit - prioritize time
156
- .setPrioritizeTimeOverSizeThresholds(true) // Prioritize time-based buffering
157
- .build()
158
-
159
- // Configure audio attributes for optimal music playback
160
- // This enables gapless audio processing in the audio pipeline
161
- val audioAttributes =
162
- AudioAttributes
163
- .Builder()
164
- .setUsage(C.USAGE_MEDIA)
165
- .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
166
- .build()
167
-
168
- player =
169
- ExoPlayer
170
- .Builder(context)
171
- .setLoadControl(loadControl)
172
- .setAudioAttributes(audioAttributes, true) // handleAudioFocus = true for gapless
173
- .setHandleAudioBecomingNoisy(true) // Pause when headphones disconnected
174
- .setPauseAtEndOfMediaItems(false) // Don't pause between items - key for gapless!
175
- .build()
176
-
177
- mediaSessionManager =
178
- MediaSessionManager(context, player, playlistManager).apply {
179
- setTrackPlayerCore(this@TrackPlayerCore)
180
- }
181
-
182
- // Set references for MediaBrowserService
183
- NitroPlayerMediaBrowserService.trackPlayerCore = this
184
- NitroPlayerMediaBrowserService.mediaSessionManager = mediaSessionManager
185
-
186
- // Initialize Android Auto connection detector
187
- androidAutoConnectionDetector =
188
- AndroidAutoConnectionDetector(context).apply {
189
- onConnectionChanged = { connected, connectionType ->
190
- handler.post {
191
- isAndroidAutoConnected = connected
192
- NitroPlayerMediaBrowserService.isAndroidAutoConnected = connected
193
-
194
- // Notify JavaScript
195
- onAndroidAutoConnectionChange?.invoke(connected)
196
-
197
- NitroPlayerLogger.log("TrackPlayerCore") { "🚗 Android Auto connection changed: connected=$connected, type=$connectionType" }
198
- }
199
- }
200
- registerCarConnectionReceiver()
201
- }
202
-
203
- val listener = object : Player.Listener {
204
- override fun onMediaItemTransition(
205
- mediaItem: MediaItem?,
206
- reason: Int,
207
- ) {
208
- NitroPlayerLogger.log("TrackPlayerCore") { "\n🔄 onMediaItemTransition called" }
209
- NitroPlayerLogger.log("TrackPlayerCore") {
210
- " reason: ${when (reason) {
211
- Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "AUTO (track ended)"
212
- Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "SEEK"
213
- Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "PLAYLIST_CHANGED"
214
- else -> "UNKNOWN($reason)"
215
- }}"
216
- }
217
- NitroPlayerLogger.log("TrackPlayerCore") { " previousMediaItem: ${previousMediaItem?.mediaId}" }
218
- NitroPlayerLogger.log("TrackPlayerCore") { " new mediaItem: ${mediaItem?.mediaId}" }
219
- NitroPlayerLogger.log("TrackPlayerCore") { " playNextStack: ${playNextStack.map { it.id }}" }
220
- NitroPlayerLogger.log("TrackPlayerCore") { " upNextQueue: ${upNextQueue.map { it.id }}" }
221
-
222
- // TRACK repeat: REPEAT_MODE_ONE fires this callback every loop — skip entirely
223
- if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
224
- NitroPlayerLogger.log("TrackPlayerCore") { " 🔁 TRACK repeat loop — skipping notifyTrackChange" }
225
- return
226
- }
227
-
228
- // Remove finished track from temporary lists
229
- // Handle AUTO (natural end) and SEEK (skip next) transitions
230
- if ((
231
- reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
232
- reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
233
- ) &&
234
- previousMediaItem != null
235
- ) {
236
- previousMediaItem?.mediaId?.let { mediaId ->
237
- val trackId = extractTrackId(mediaId)
238
- NitroPlayerLogger.log("TrackPlayerCore") { "🏁 Track finished/skipped, checking for removal: $trackId" }
239
-
240
- // Find and remove from playNext stack (like iOS does)
241
- val playNextIndex = playNextStack.indexOfFirst { it.id == trackId }
242
- if (playNextIndex >= 0) {
243
- val track = playNextStack.removeAt(playNextIndex)
244
- NitroPlayerLogger.log("TrackPlayerCore") { " ✅ Removed from playNext stack: ${track.title}" }
245
- } else {
246
- // Find and remove from upNext queue
247
- val upNextIndex = upNextQueue.indexOfFirst { it.id == trackId }
248
- if (upNextIndex >= 0) {
249
- val track = upNextQueue.removeAt(upNextIndex)
250
- NitroPlayerLogger.log("TrackPlayerCore") { " ✅ Removed from upNext queue: ${track.title}" }
251
- } else {
252
- NitroPlayerLogger.log("TrackPlayerCore") { " ℹ️ Was an original playlist track" }
253
- }
254
- }
255
- }
256
- } else {
257
- NitroPlayerLogger.log("TrackPlayerCore") { " ⏭️ Skipping removal (reason=$reason, prev=${previousMediaItem != null})" }
258
- }
259
-
260
- // Store current item as previous for next transition
261
- previousMediaItem = mediaItem
262
-
263
- // Update temporary type for current track
264
- currentTemporaryType = determineCurrentTemporaryType()
265
- NitroPlayerLogger.log("TrackPlayerCore") { " Updated currentTemporaryType: $currentTemporaryType" }
266
-
267
- // Update currentTrackIndex when we land on an original playlist track
268
- if (currentTemporaryType == TemporaryType.NONE && mediaItem != null) {
269
- val trackId = extractTrackId(mediaItem.mediaId)
270
- val newIndex = currentTracks.indexOfFirst { it.id == trackId }
271
- if (newIndex >= 0 && newIndex != currentTrackIndex) {
272
- NitroPlayerLogger.log("TrackPlayerCore") { " 📍 Updating currentTrackIndex from $currentTrackIndex to $newIndex" }
273
- currentTrackIndex = newIndex
274
- }
275
- }
276
-
277
- // Handle playlist switching if needed
278
- mediaItem?.mediaId?.let { mediaId ->
279
- if (mediaId.contains(':')) {
280
- val colonIndex = mediaId.indexOf(':')
281
- val playlistId = mediaId.substring(0, colonIndex)
282
- if (playlistId != currentPlaylistId) {
283
- // Track from different playlist - ensure playlist is loaded
284
- val playlist = playlistManager.getPlaylist(playlistId)
285
- if (playlist != null && currentPlaylistId != playlistId) {
286
- // This shouldn't happen if playlists are loaded correctly,
287
- // but handle it as a safety measure
288
- NitroPlayerLogger.log(
289
- "TrackPlayerCore",
290
- "⚠️ TrackPlayerCore: Detected track from different playlist, updating...",
291
- )
292
- }
293
- }
294
- }
295
- }
296
-
297
- // Use getCurrentTrack() which handles temporary tracks properly
298
- val track = getCurrentTrack()
299
- if (track != null) {
300
- val r =
301
- when (reason) {
302
- Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> Reason.END
303
- Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> Reason.USER_ACTION
304
- Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> Reason.USER_ACTION
305
- else -> null
306
- }
307
- notifyTrackChange(track, r)
308
- mediaSessionManager?.onTrackChanged()
309
-
310
- // Check if upcoming tracks need URLs
311
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
312
- }
313
- }
314
-
315
- override fun onTimelineChanged(
316
- timeline: androidx.media3.common.Timeline,
317
- reason: Int,
318
- ) {
319
- if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
320
- // Playlist changed - update MediaBrowserService
321
- NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
322
- }
323
- }
324
-
325
- override fun onPlayWhenReadyChanged(
326
- playWhenReady: Boolean,
327
- reason: Int,
328
- ) {
329
- val r =
330
- when (reason) {
331
- Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST -> Reason.USER_ACTION
332
- else -> null
333
- }
334
- emitStateChange(r)
335
- }
336
-
337
- override fun onPlaybackStateChanged(playbackState: Int) {
338
- if (playbackState == Player.STATE_ENDED && currentRepeatMode == RepeatMode.PLAYLIST) {
339
- NitroPlayerLogger.log("TrackPlayerCore") { "🔁 PLAYLIST repeat — rebuilding original queue and restarting" }
340
- handler.post {
341
- playNextStack.clear()
342
- upNextQueue.clear()
343
- currentTemporaryType = TemporaryType.NONE
344
- // Rebuild ExoPlayer queue from beginning of original playlist
345
- rebuildQueueAndPlayFromIndex(0)
346
- val firstTrack = currentTracks.getOrNull(0)
347
- if (firstTrack != null) notifyTrackChange(firstTrack, Reason.REPEAT)
348
- }
349
- return
350
- }
351
- emitStateChange()
352
- }
353
-
354
- override fun onIsPlayingChanged(isPlaying: Boolean) {
355
- emitStateChange()
356
- }
357
-
358
- override fun onPositionDiscontinuity(
359
- oldPosition: Player.PositionInfo,
360
- newPosition: Player.PositionInfo,
361
- reason: Int,
362
- ) {
363
- if (reason == Player.DISCONTINUITY_REASON_SEEK) {
364
- isManuallySeeked = true
365
- notifySeek(newPosition.positionMs / 1000.0, player.duration / 1000.0)
366
- }
367
- }
368
-
369
- override fun onAudioSessionIdChanged(audioSessionId: Int) {
370
- if (audioSessionId != 0) {
371
- try {
372
- EqualizerCore.getInstance(context).initialize(audioSessionId)
373
- } catch (e: Exception) {
374
- // Equalizer initialization failed - non-critical
375
- }
376
- }
377
- }
378
- }
379
- playerListener = listener
380
- player.addListener(listener)
381
-
382
- // Start progress updates
383
- handler.post(progressUpdateRunnable)
384
- }
385
-
386
- // Execute on main thread: if already on main thread, run synchronously to avoid deadlock
387
- if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
388
- initRunnable.run()
389
- } else {
390
- handler.post(initRunnable)
391
- }
392
- }
393
-
394
- /**
395
- * Load a playlist for playback using ExoPlayer's native playlist API
396
- * Based on: https://developer.android.com/media/media3/exoplayer/playlists
397
- */
398
- fun loadPlaylist(playlistId: String) {
399
- handler.post {
400
- // Clear temporary tracks when loading new playlist
401
- playNextStack.clear()
402
- upNextQueue.clear()
403
- currentTemporaryType = TemporaryType.NONE
404
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
405
-
406
- val playlist = playlistManager.getPlaylist(playlistId)
407
- if (playlist != null) {
408
- currentPlaylistId = playlistId
409
- updatePlayerQueue(playlist.tracks)
410
-
411
- // Check if upcoming tracks need URLs
412
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
413
- }
414
- }
415
- }
416
-
417
- /**
418
- * Play a specific track from a playlist (for Android Auto)
419
- * MediaId format: "playlistId:trackId"
420
- */
421
- fun playFromPlaylistTrack(mediaId: String) {
422
- handler.post {
423
- try {
424
- // Parse mediaId: "playlistId:trackId"
425
- val colonIndex = mediaId.indexOf(':')
426
- if (colonIndex > 0 && colonIndex < mediaId.length - 1) {
427
- val playlistId = mediaId.substring(0, colonIndex)
428
- val trackId = mediaId.substring(colonIndex + 1)
429
-
430
- val playlist = playlistManager.getPlaylist(playlistId)
431
- if (playlist != null) {
432
- val trackIndex = playlist.tracks.indexOfFirst { it.id == trackId }
433
- if (trackIndex >= 0) {
434
- // Load playlist if not already loaded
435
- if (currentPlaylistId != playlistId) {
436
- loadPlaylist(playlistId)
437
- // Wait a bit for playlist to load, then seek
438
- handler.postDelayed({
439
- playFromIndex(trackIndex)
440
- }, 100)
441
- } else {
442
- // Playlist already loaded, just seek to track
443
- playFromIndex(trackIndex)
444
- }
445
- }
446
- }
447
- }
448
- } catch (e: Exception) {
449
- NitroPlayerLogger.log("TrackPlayerCore") { "❌ TrackPlayerCore: Error playing from playlist track - ${e.message}" }
450
- e.printStackTrace()
451
- }
452
- }
453
- }
454
-
455
- /**
456
- * Update the player queue when playlist changes
457
- */
458
- fun updatePlaylist(playlistId: String) {
459
- // Debounce: rapid back-to-back calls (e.g. removing N tracks then adding
460
- // the shuffled replacement) are coalesced into a single setMediaItems call.
461
- // removeCallbacks cancels any pending-but-not-yet-executed callback so only
462
- // the final playlist state triggers a player rebuild.
463
- if (currentPlaylistId != playlistId) return
464
- handler.removeCallbacks(updateCurrentPlaylistRunnable)
465
- handler.post(updateCurrentPlaylistRunnable)
466
- }
467
-
468
- /**
469
- * Get current playlist ID
470
- */
471
- fun getCurrentPlaylistId(): String? = currentPlaylistId
472
-
473
- /**
474
- * Get playlist manager (for access from other classes like Google Cast)
475
- */
476
- fun getPlaylistManager(): PlaylistManager = playlistManager
477
-
478
- private fun emitStateChange(reason: Reason? = null) {
479
- val state =
480
- when (player.playbackState) {
481
- Player.STATE_IDLE -> TrackPlayerState.STOPPED
482
- Player.STATE_BUFFERING -> if (player.playWhenReady) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
483
- Player.STATE_READY -> if (player.isPlaying) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
484
- Player.STATE_ENDED -> TrackPlayerState.STOPPED
485
- else -> TrackPlayerState.STOPPED
486
- }
487
-
488
- val actualReason = reason ?: if (player.playbackState == Player.STATE_ENDED) Reason.END else null
489
- notifyPlaybackStateChange(state, actualReason)
490
- mediaSessionManager?.onPlaybackStateChanged()
491
- }
492
-
493
- private fun updatePlayerQueue(tracks: List<TrackItem>) {
494
- // Store the original tracks
495
- currentTracks = tracks
496
-
497
- // Create MediaItems with playlist info in mediaId for Android Auto
498
- val mediaItems =
499
- tracks.mapIndexed { index, track ->
500
- val playlistId = currentPlaylistId ?: ""
501
- // Format: "playlistId:trackId" so we can identify playlist and track
502
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
503
- track.toMediaItem(mediaId)
504
- }
505
-
506
- player.setMediaItems(mediaItems, false)
507
- if (player.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
508
- player.prepare()
509
- }
510
- }
511
-
512
- private fun TrackItem.toMediaItem(customMediaId: String? = null): MediaItem {
513
- val metadataBuilder =
514
- MediaMetadata
515
- .Builder()
516
- .setTitle(title)
517
- .setArtist(artist)
518
- .setAlbumTitle(album)
519
-
520
- artwork?.asSecondOrNull()?.let { artworkUrl ->
521
- try {
522
- metadataBuilder.setArtworkUri(Uri.parse(artworkUrl))
523
- } catch (e: Exception) {
524
- // Ignore invalid artwork URI
525
- }
526
- }
527
-
528
- // Use downloadManager.getEffectiveUrl to automatically get local path if downloaded
529
- val effectiveUrl = downloadManager.getEffectiveUrl(this)
530
-
531
- return MediaItem
532
- .Builder()
533
- .setMediaId(customMediaId ?: id)
534
- .setUri(effectiveUrl)
535
- .setMediaMetadata(metadataBuilder.build())
536
- .build()
122
+ // ExoPlayer must be created on its own thread
123
+ playerHandler.post { initExoAndMedia() }
124
+ // Android Auto receiver must be registered on the main thread
125
+ handler.post { setupAndroidAutoDetector() }
537
126
  }
538
127
 
539
- private fun findTrack(mediaItem: MediaItem?): TrackItem? {
540
- if (mediaItem == null) return null
128
+ // ── Coroutine bridge to player thread ──────────────────────────────────
541
129
 
542
- val mediaId = mediaItem.mediaId
543
- val trackId =
544
- if (mediaId.contains(':')) {
545
- // Format: "playlistId:trackId"
546
- mediaId.substring(mediaId.indexOf(':') + 1)
547
- } else {
548
- mediaId
130
+ internal suspend fun <T> withPlayerContext(block: () -> T): T {
131
+ if (Looper.myLooper() == playerThread.looper) return block()
132
+ return suspendCancellableCoroutine { cont ->
133
+ val r = Runnable {
134
+ try { cont.resume(block()) }
135
+ catch (e: Exception) { cont.resumeWithException(e) }
549
136
  }
550
-
551
- // currentTracks is already the cached tracks for currentPlaylistId — no need to
552
- // re-fetch from PlaylistManager on every call.
553
- return currentTracks.find { it.id == trackId }
554
- }
555
-
556
- fun play() {
557
- handler.post { player.play() }
558
- }
559
-
560
- fun pause() {
561
- handler.post { player.pause() }
562
- }
563
-
564
- fun playSong(
565
- songId: String,
566
- fromPlaylist: String?,
567
- ) {
568
- handler.post {
569
- playSongInternal(songId, fromPlaylist)
137
+ playerHandler.post(r)
138
+ cont.invokeOnCancellation { playerHandler.removeCallbacks(r) }
570
139
  }
571
140
  }
572
141
 
573
- private fun playSongInternal(
574
- songId: String,
575
- fromPlaylist: String?,
576
- ) {
577
- // Clear temporary tracks when directly playing a song
578
- playNextStack.clear()
579
- upNextQueue.clear()
580
- currentTemporaryType = TemporaryType.NONE
581
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
582
-
583
- var targetPlaylistId: String? = null
584
- var songIndex: Int = -1
585
-
586
- // Case 1: If fromPlaylist is provided, use that playlist
587
- if (fromPlaylist != null) {
588
- NitroPlayerLogger.log("TrackPlayerCore") { "🎵 TrackPlayerCore: Looking for song in specified playlist: $fromPlaylist" }
589
- val playlist = playlistManager.getPlaylist(fromPlaylist)
590
- if (playlist != null) {
591
- songIndex = playlist.tracks.indexOfFirst { it.id == songId }
592
- if (songIndex >= 0) {
593
- targetPlaylistId = fromPlaylist
594
- NitroPlayerLogger.log("TrackPlayerCore") { "✅ Found song at index $songIndex in playlist $fromPlaylist" }
595
- } else {
596
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Song $songId not found in specified playlist $fromPlaylist" }
597
- return
598
- }
599
- } else {
600
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Playlist $fromPlaylist not found" }
601
- return
602
- }
603
- }
604
- // Case 2: If fromPlaylist is not provided, search in current/loaded playlist first
605
- else {
606
- NitroPlayerLogger.log("TrackPlayerCore", "🎵 TrackPlayerCore: No playlist specified, checking current playlist")
607
-
608
- // Check if song exists in currently loaded playlist
609
- if (currentPlaylistId != null) {
610
- val currentPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
611
- if (currentPlaylist != null) {
612
- songIndex = currentPlaylist.tracks.indexOfFirst { it.id == songId }
613
- if (songIndex >= 0) {
614
- targetPlaylistId = currentPlaylistId
615
- NitroPlayerLogger.log("TrackPlayerCore") { "✅ Found song at index $songIndex in current playlist $currentPlaylistId" }
616
- }
617
- }
618
- }
619
-
620
- // If not found in current playlist, search in all playlists
621
- if (songIndex == -1) {
622
- NitroPlayerLogger.log("TrackPlayerCore", "🔍 Song not found in current playlist, searching all playlists...")
623
- val allPlaylists = playlistManager.getAllPlaylists()
624
-
625
- for (playlist in allPlaylists) {
626
- songIndex = playlist.tracks.indexOfFirst { it.id == songId }
627
- if (songIndex >= 0) {
628
- targetPlaylistId = playlist.id
629
- NitroPlayerLogger.log("TrackPlayerCore") { "✅ Found song at index $songIndex in playlist ${playlist.id}" }
630
- break
631
- }
632
- }
633
-
634
- // If still not found, just use the first playlist if available
635
- if (songIndex == -1 && allPlaylists.isNotEmpty()) {
636
- targetPlaylistId = allPlaylists[0].id
637
- songIndex = 0
638
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Song not found in any playlist, using first playlist and starting at index 0")
639
- }
640
- }
641
- }
642
-
643
- // Now play the song
644
- if (targetPlaylistId == null || songIndex < 0) {
645
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Could not determine playlist or song index")
646
- return
647
- }
648
-
649
- // Load playlist if it's different from current
650
- if (currentPlaylistId != targetPlaylistId) {
651
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 Loading new playlist: $targetPlaylistId" }
652
- val playlist = playlistManager.getPlaylist(targetPlaylistId)
653
- if (playlist != null) {
654
- currentPlaylistId = targetPlaylistId
655
- updatePlayerQueue(playlist.tracks)
656
-
657
- // Wait a bit for playlist to load, then play from index
658
- // Note: Removed postDelayed to avoid race conditions with subsequent queue operations
659
- NitroPlayerLogger.log("TrackPlayerCore") { "▶️ Playing from index: $songIndex" }
660
- playFromIndex(songIndex)
661
- }
662
- } else {
663
- // Playlist already loaded, just play from index
664
- NitroPlayerLogger.log("TrackPlayerCore") { "▶️ Playing from index: $songIndex" }
665
- playFromIndex(songIndex)
666
- }
667
- }
668
-
669
- fun skipToNext() {
670
- handler.post {
671
- if (player.hasNextMediaItem()) {
672
- player.seekToNextMediaItem()
673
-
674
- // Check if upcoming tracks need URLs
675
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
676
- }
677
- }
678
- }
679
-
680
- fun skipToPrevious() {
681
- handler.post {
682
- val currentPosition = player.currentPosition // milliseconds
683
-
684
- if (currentPosition > 2000) {
685
- // More than 2 seconds in, restart current track
686
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 TrackPlayerCore: Past threshold, restarting current track")
687
- player.seekTo(0)
688
- } else if (currentTemporaryType != TemporaryType.NONE) {
689
- // Playing temporary track within threshold — remove from its list, go back to original
690
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 TrackPlayerCore: Removing temp track, going back to original")
691
- val currentMediaItem = player.currentMediaItem
692
- if (currentMediaItem != null) {
693
- val trackId = extractTrackId(currentMediaItem.mediaId)
694
- when (currentTemporaryType) {
695
- TemporaryType.PLAY_NEXT -> {
696
- val idx = playNextStack.indexOfFirst { it.id == trackId }
697
- if (idx >= 0) playNextStack.removeAt(idx)
698
- }
699
-
700
- TemporaryType.UP_NEXT -> {
701
- val idx = upNextQueue.indexOfFirst { it.id == trackId }
702
- if (idx >= 0) upNextQueue.removeAt(idx)
703
- }
704
-
705
- else -> {}
706
- }
707
- }
708
- currentTemporaryType = TemporaryType.NONE
709
- playFromIndexInternal(currentTrackIndex)
710
- } else if (currentTrackIndex > 0) {
711
- // Go to previous track in original playlist
712
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 TrackPlayerCore: Going to previous track, currentTrackIndex: $currentTrackIndex -> ${currentTrackIndex - 1}" }
713
- playFromIndexInternal(currentTrackIndex - 1)
714
- } else {
715
- // Already at first track, seek to beginning
716
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 TrackPlayerCore: Already at first track, seeking to beginning")
717
- player.seekTo(0)
718
- }
719
-
720
- // Check if upcoming tracks need URLs
721
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
722
- }
723
- }
724
-
725
- fun seek(position: Double) {
726
- handler.post {
727
- isManuallySeeked = true
728
- player.seekTo((position * 1000).toLong())
729
- }
730
- }
142
+ // ── Lifecycle ──────────────────────────────────────────────────────────
731
143
 
732
- fun setRepeatMode(mode: RepeatMode): Boolean {
733
- currentRepeatMode = mode
734
- if (::player.isInitialized) {
735
- handler.post {
736
- player.repeatMode =
737
- when (mode) {
738
- RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
739
- else -> Player.REPEAT_MODE_OFF
740
- }
741
- }
742
- }
743
- NitroPlayerLogger.log("TrackPlayerCore") { "🔁 setRepeatMode: $mode" }
744
- return true
745
- }
746
-
747
- fun getRepeatMode(): RepeatMode = currentRepeatMode
748
-
749
- fun getState(): PlayerState {
750
- // Called from Promise.async background thread
751
- // Check if we're already on the main thread
752
- if (android.os.Looper.myLooper() == handler.looper) {
753
- return getStateInternal()
754
- }
755
-
756
- // Use CountDownLatch to wait for the result on the main thread
757
- val latch = CountDownLatch(1)
758
- var result: PlayerState? = null
759
-
760
- handler.post {
761
- try {
762
- result = getStateInternal()
763
- } finally {
764
- latch.countDown()
765
- }
766
- }
767
-
768
- try {
769
- // Wait up to 5 seconds for the result
770
- latch.await(5, TimeUnit.SECONDS)
771
- } catch (e: InterruptedException) {
772
- Thread.currentThread().interrupt()
773
- }
774
-
775
- return result ?: PlayerState(
776
- currentTrack = null,
777
- currentPosition = 0.0,
778
- totalDuration = 0.0,
779
- currentState = TrackPlayerState.STOPPED,
780
- currentPlaylistId = null,
781
- currentIndex = -1.0,
782
- currentPlayingType = CurrentPlayingType.NOT_PLAYING
783
- )
784
- }
785
-
786
- private fun getStateInternal(): PlayerState =
787
- if (::player.isInitialized) {
788
- // Use getCurrentTrack() which handles temporary tracks properly
789
- val track = getCurrentTrack()
790
-
791
- // Convert nullable TrackItem to Variant_NullType_TrackItem
792
- val currentTrack: Variant_NullType_TrackItem? =
793
- if (track != null) {
794
- Variant_NullType_TrackItem.create(track)
795
- } else {
796
- null
797
- }
798
-
799
- val currentPosition = player.currentPosition / 1000.0
800
- val totalDuration = if (player.duration > 0) player.duration / 1000.0 else 0.0
801
-
802
- val currentState =
803
- when (player.playbackState) {
804
- Player.STATE_IDLE -> TrackPlayerState.STOPPED
805
- Player.STATE_BUFFERING -> if (player.playWhenReady) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
806
- Player.STATE_READY -> if (player.isPlaying) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
807
- Player.STATE_ENDED -> TrackPlayerState.STOPPED
808
- else -> TrackPlayerState.STOPPED
809
- }
810
-
811
- // Use ExoPlayer's currentMediaItemIndex
812
- val currentIndex =
813
- if (player.currentMediaItemIndex >= 0) {
814
- player.currentMediaItemIndex.toDouble()
815
- } else {
816
- -1.0
817
- }
818
-
819
- // Map internal temporary type to CurrentPlayingType
820
- val currentPlayingTypeValue =
821
- if (track == null) {
822
- CurrentPlayingType.NOT_PLAYING
823
- } else {
824
- when (currentTemporaryType) {
825
- TemporaryType.NONE -> CurrentPlayingType.PLAYLIST
826
- TemporaryType.PLAY_NEXT -> CurrentPlayingType.PLAY_NEXT
827
- TemporaryType.UP_NEXT -> CurrentPlayingType.UP_NEXT
828
- }
829
- }
830
-
831
- PlayerState(
832
- currentTrack = currentTrack,
833
- currentPosition = currentPosition,
834
- totalDuration = totalDuration,
835
- currentState = currentState,
836
- currentPlaylistId = currentPlaylistId?.let { Variant_NullType_String.create(it) },
837
- currentIndex = currentIndex,
838
- currentPlayingType = currentPlayingTypeValue,
839
- )
840
- } else {
841
- // Return default state if player is not initialized
842
- PlayerState(
843
- currentTrack = null,
844
- currentPosition = 0.0,
845
- totalDuration = 0.0,
846
- currentState = TrackPlayerState.STOPPED,
847
- currentPlaylistId = currentPlaylistId?.let { Variant_NullType_String.create(it) },
848
- currentIndex = -1.0,
849
- currentPlayingType = CurrentPlayingType.NOT_PLAYING,
850
- )
851
- }
852
-
853
- fun configure(
854
- androidAutoEnabled: Boolean?,
855
- carPlayEnabled: Boolean?,
856
- showInNotification: Boolean?,
857
- lookaheadCount: Int? = null,
858
- ) {
859
- handler.post {
860
- androidAutoEnabled?.let {
861
- NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it
862
- }
863
- lookaheadCount?.let {
864
- this.lookaheadCount = it
865
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 Lookahead count set to: $it" }
866
- }
867
- mediaSessionManager?.configure(
868
- androidAutoEnabled,
869
- carPlayEnabled,
870
- showInNotification,
871
- )
872
- }
873
- }
874
-
875
- // Public method to get all playlists (for MediaBrowserService and other classes)
876
- fun getAllPlaylists(): List<com.margelo.nitro.nitroplayer.playlist.Playlist> = playlistManager.getAllPlaylists()
877
-
878
- // Public method to get current track for MediaBrowserService
879
- fun getCurrentTrack(): TrackItem? {
880
- if (!::player.isInitialized) return null
881
- val currentMediaItem = player.currentMediaItem ?: return null
882
-
883
- // If playing a temporary track, return that
884
- if (currentTemporaryType != TemporaryType.NONE) {
885
- val trackId = extractTrackId(currentMediaItem.mediaId)
886
-
887
- when (currentTemporaryType) {
888
- TemporaryType.PLAY_NEXT -> {
889
- return playNextStack.firstOrNull { it.id == trackId }
890
- }
891
-
892
- TemporaryType.UP_NEXT -> {
893
- return upNextQueue.firstOrNull { it.id == trackId }
894
- }
895
-
896
- else -> {}
897
- }
898
- }
899
-
900
- // Otherwise return from original playlist
901
- return findTrack(currentMediaItem)
902
- }
903
-
904
- private fun extractTrackId(mediaId: String): String =
905
- if (mediaId.contains(':')) {
906
- // Format: "playlistId:trackId"
907
- mediaId.substring(mediaId.indexOf(':') + 1)
908
- } else {
909
- mediaId
910
- }
911
-
912
- // Public method to play from a specific index (for Android Auto)
913
- // Public method to play from a specific index (for Android Auto)
914
- fun playFromIndex(index: Int) {
915
- if (android.os.Looper.myLooper() == handler.looper) {
916
- playFromIndexInternal(index)
917
- } else {
918
- handler.post {
919
- playFromIndexInternal(index)
920
- }
921
- }
922
- }
923
-
924
- // MARK: - Skip to Index in Actual Queue
925
-
926
- fun skipToIndex(index: Int): Boolean {
927
- // Check if we're already on the main thread
928
- if (android.os.Looper.myLooper() == handler.looper) {
929
- return skipToIndexInternal(index)
930
- }
931
-
932
- // Use CountDownLatch to wait for the result on the main thread
933
- val latch = CountDownLatch(1)
934
- var result = false
935
-
936
- handler.post {
937
- try {
938
- result = skipToIndexInternal(index)
939
- } finally {
940
- latch.countDown()
941
- }
942
- }
943
-
944
- try {
945
- // Wait up to 5 seconds for the result
946
- latch.await(5, TimeUnit.SECONDS)
947
- } catch (e: InterruptedException) {
948
- Thread.currentThread().interrupt()
949
- }
950
-
951
- return result
952
- }
953
-
954
- private fun skipToIndexInternal(index: Int): Boolean {
955
- if (!::player.isInitialized) return false
956
-
957
- // Get actual queue to validate index and determine position
958
- val actualQueue = getActualQueueInternal()
959
- val totalQueueSize = actualQueue.size
960
-
961
- // Validate index
962
- if (index < 0 || index >= totalQueueSize) return false
963
-
964
- // Calculate queue section boundaries using effective sizes
965
- // (reduced by 1 when current track is from that temp list, matching getActualQueueInternal)
966
- // When temp is playing, the original track at currentTrackIndex is included in "before",
967
- // so the current playing position shifts by 1
968
- val currentPos =
969
- if (currentTemporaryType != TemporaryType.NONE) {
970
- currentTrackIndex + 1
971
- } else {
972
- currentTrackIndex
973
- }
974
- val effectivePlayNextSize =
975
- if (currentTemporaryType == TemporaryType.PLAY_NEXT) {
976
- maxOf(0, playNextStack.size - 1)
977
- } else {
978
- playNextStack.size
979
- }
980
- val effectiveUpNextSize =
981
- if (currentTemporaryType == TemporaryType.UP_NEXT) {
982
- maxOf(0, upNextQueue.size - 1)
983
- } else {
984
- upNextQueue.size
985
- }
986
-
987
- val playNextStart = currentPos + 1
988
- val playNextEnd = playNextStart + effectivePlayNextSize
989
- val upNextStart = playNextEnd
990
- val upNextEnd = upNextStart + effectiveUpNextSize
991
- val originalRemainingStart = upNextEnd
992
-
993
- // Case 1: Target is before current - use playFromIndex on original
994
- if (index < currentPos) {
995
- playFromIndexInternal(index)
996
- return true
997
- }
998
-
999
- // Case 2: Target is current - seek to beginning
1000
- if (index == currentPos) {
1001
- player.seekTo(0)
1002
- return true
1003
- }
1004
-
1005
- // Case 3: Target is in playNext section
1006
- if (index >= playNextStart && index < playNextEnd) {
1007
- val playNextIndex = index - playNextStart
1008
- // Offset by 1 if current is from playNext (index 0 is already playing)
1009
- val actualListIndex =
1010
- if (currentTemporaryType == TemporaryType.PLAY_NEXT) {
1011
- playNextIndex + 1
1012
- } else {
1013
- playNextIndex
1014
- }
1015
-
1016
- // Remove tracks before the target from playNext (they're being skipped)
1017
- if (actualListIndex > 0) {
1018
- playNextStack.subList(0, actualListIndex).clear()
1019
- }
1020
-
1021
- // Rebuild queue and advance
1022
- rebuildQueueFromCurrentPosition()
1023
- player.seekToNextMediaItem()
1024
- return true
1025
- }
1026
-
1027
- // Case 4: Target is in upNext section
1028
- if (index >= upNextStart && index < upNextEnd) {
1029
- val upNextIndex = index - upNextStart
1030
- // Offset by 1 if current is from upNext (index 0 is already playing)
1031
- val actualListIndex =
1032
- if (currentTemporaryType == TemporaryType.UP_NEXT) {
1033
- upNextIndex + 1
1034
- } else {
1035
- upNextIndex
1036
- }
1037
-
1038
- // Clear all playNext tracks (they're being skipped)
1039
- playNextStack.clear()
1040
-
1041
- // Remove tracks before target from upNext
1042
- if (actualListIndex > 0) {
1043
- upNextQueue.subList(0, actualListIndex).clear()
1044
- }
1045
-
1046
- // Rebuild queue and advance
1047
- rebuildQueueFromCurrentPosition()
1048
- player.seekToNextMediaItem()
1049
- return true
1050
- }
1051
-
1052
- // Case 5: Target is in remaining original tracks
1053
- if (index >= originalRemainingStart) {
1054
- val targetTrack = actualQueue[index]
1055
-
1056
- // Find this track's index in the original playlist
1057
- val originalIndex = currentTracks.indexOfFirst { it.id == targetTrack.id }
1058
- if (originalIndex == -1) return false
1059
-
1060
- // Clear all temporary tracks (they're being skipped)
1061
- playNextStack.clear()
1062
- upNextQueue.clear()
1063
- currentTemporaryType = TemporaryType.NONE
1064
-
1065
- rebuildQueueAndPlayFromIndex(originalIndex)
1066
-
1067
- // Check if upcoming tracks need URLs
1068
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
1069
-
1070
- return true
1071
- }
1072
-
1073
- // Check if upcoming tracks need URLs after any successful skip
1074
- checkUpcomingTracksForUrls(lookahead = lookaheadCount)
1075
-
1076
- return false
1077
- }
1078
-
1079
- private fun playFromIndexInternal(index: Int) {
1080
- // Clear temporary tracks when jumping to specific index
1081
- playNextStack.clear()
1082
- upNextQueue.clear()
1083
- currentTemporaryType = TemporaryType.NONE
1084
-
1085
- rebuildQueueAndPlayFromIndex(index)
1086
- }
1087
-
1088
- /**
1089
- * Rebuild the entire ExoPlayer queue from the original playlist starting at the given index
1090
- * This clears all temporary tracks and rebuilds the queue fresh
1091
- */
1092
- private fun rebuildQueueAndPlayFromIndex(index: Int) {
1093
- if (!::player.isInitialized) {
1094
- NitroPlayerLogger.log("TrackPlayerCore", " ❌ Player not initialized")
1095
- return
1096
- }
1097
-
1098
- if (index < 0 || index >= currentTracks.size) {
1099
- NitroPlayerLogger.log("TrackPlayerCore") { " ❌ Invalid index $index for currentTracks size ${currentTracks.size}" }
1100
- return
1101
- }
1102
-
1103
- NitroPlayerLogger.log("TrackPlayerCore") { "\n🔄 TrackPlayerCore: REBUILD QUEUE AND PLAY FROM INDEX $index" }
1104
- NitroPlayerLogger.log("TrackPlayerCore") { " currentTracks.size: ${currentTracks.size}" }
1105
- NitroPlayerLogger.log("TrackPlayerCore") { " currentTracks IDs: ${currentTracks.map { it.id }}" }
1106
-
1107
- // Build queue from the target index onwards
1108
- val tracksToPlay = currentTracks.subList(index, currentTracks.size)
1109
- NitroPlayerLogger.log("TrackPlayerCore") { " tracksToPlay (${tracksToPlay.size}): ${tracksToPlay.map { it.id }}" }
1110
-
1111
- val playlistId = currentPlaylistId ?: ""
1112
- val mediaItems =
1113
- tracksToPlay.map { track ->
1114
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
1115
- track.toMediaItem(mediaId)
1116
- }
1117
-
1118
- // Update our internal tracking of the position in original playlist
1119
- currentTrackIndex = index
1120
- NitroPlayerLogger.log("TrackPlayerCore") { " Setting currentTrackIndex to $index" }
1121
-
1122
- // Clear the entire player queue and set new items
1123
- player.clearMediaItems()
1124
- player.setMediaItems(mediaItems)
1125
- player.seekToDefaultPosition(0) // Seek to first item (which is our target track)
1126
- player.playWhenReady = true
1127
- player.prepare()
1128
-
1129
- NitroPlayerLogger.log("TrackPlayerCore") { " ✅ Queue rebuilt with ${player.mediaItemCount} items, playing from index 0 (track ${tracksToPlay.firstOrNull()?.id})" }
1130
- }
1131
-
1132
- // MARK: - Temporary Track Management
1133
-
1134
- /**
1135
- * Add a track to the up-next queue (FIFO - first added plays first)
1136
- * Track will be inserted after currently playing track and any playNext tracks
1137
- */
1138
- fun addToUpNext(trackId: String) {
1139
- handler.post {
1140
- addToUpNextInternal(trackId)
1141
- }
1142
- }
1143
-
1144
- private fun addToUpNextInternal(trackId: String) {
1145
- NitroPlayerLogger.log("TrackPlayerCore") { "📋 TrackPlayerCore: addToUpNext($trackId)" }
1146
-
1147
- // Find the track from current playlist or all playlists
1148
- val track = findTrackById(trackId)
1149
- if (track == null) {
1150
- NitroPlayerLogger.log("TrackPlayerCore") { "❌ TrackPlayerCore: Track $trackId not found" }
1151
- return
1152
- }
1153
-
1154
- // Add to end of upNext queue (FIFO)
1155
- upNextQueue.add(track)
1156
- NitroPlayerLogger.log("TrackPlayerCore") { " ✅ Added '${track.title}' to upNext queue (position: ${upNextQueue.size})" }
1157
-
1158
- // Rebuild the player queue if actively playing
1159
- if (::player.isInitialized && player.currentMediaItem != null) {
1160
- rebuildQueueFromCurrentPosition()
1161
- }
1162
- }
1163
-
1164
- /**
1165
- * Add a track to play next (LIFO - last added plays first)
1166
- * Track will be inserted immediately after currently playing track
1167
- */
1168
- fun playNext(trackId: String) {
1169
- handler.post {
1170
- playNextInternal(trackId)
1171
- }
1172
- }
1173
-
1174
- private fun playNextInternal(trackId: String) {
1175
- NitroPlayerLogger.log("TrackPlayerCore") { "⏭️ TrackPlayerCore: playNext($trackId)" }
1176
-
1177
- // Find the track from current playlist or all playlists
1178
- val track = findTrackById(trackId)
1179
- if (track == null) {
1180
- NitroPlayerLogger.log("TrackPlayerCore") { "❌ TrackPlayerCore: Track $trackId not found" }
1181
- return
1182
- }
1183
-
1184
- // Insert at beginning of playNext stack (LIFO)
1185
- playNextStack.add(0, track)
1186
- NitroPlayerLogger.log("TrackPlayerCore") { " ✅ Added '${track.title}' to playNext stack (position: 1)" }
1187
-
1188
- // Rebuild the player queue if actively playing
1189
- if (::player.isInitialized && player.currentMediaItem != null) {
1190
- rebuildQueueFromCurrentPosition()
1191
- }
1192
- }
1193
-
1194
- /**
1195
- * Rebuild the ExoPlayer queue from current position with temporary tracks
1196
- * Order: [current] + [playNext stack] + [upNext queue] + [remaining original]
1197
- */
1198
- private fun rebuildQueueFromCurrentPosition() {
1199
- if (!::player.isInitialized) return
1200
-
1201
- val currentIndex = player.currentMediaItemIndex
1202
- if (currentIndex < 0) return
1203
-
1204
- // Handle removed-current-track case: if the currently playing media item is no longer
1205
- // in currentTracks (e.g. the user removed it while it was playing), delegate to
1206
- // playFromIndexInternal so the player immediately starts the next track.
1207
- val currentTrackId = player.currentMediaItem?.mediaId?.let { extractTrackId(it) }
1208
- if (currentTrackId != null && currentTracks.none { it.id == currentTrackId }) {
1209
- val targetIndex = when {
1210
- currentTracks.isEmpty() -> return
1211
- currentTrackIndex < currentTracks.size -> currentTrackIndex
1212
- else -> currentTracks.size - 1
1213
- }
1214
- playFromIndexInternal(targetIndex)
1215
- return
1216
- }
1217
-
1218
- val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
1219
-
1220
- // Add playNext stack (LIFO - most recently added plays first)
1221
- // Skip index 0 if current track is from playNext (it's already playing)
1222
- if (currentTemporaryType == TemporaryType.PLAY_NEXT && playNextStack.size > 1) {
1223
- newQueueTracks.addAll(playNextStack.subList(1, playNextStack.size))
1224
- } else if (currentTemporaryType != TemporaryType.PLAY_NEXT) {
1225
- newQueueTracks.addAll(playNextStack)
1226
- }
1227
-
1228
- // Add upNext queue (in order, FIFO)
1229
- // Skip index 0 if current track is from upNext (it's already playing)
1230
- if (currentTemporaryType == TemporaryType.UP_NEXT && upNextQueue.size > 1) {
1231
- newQueueTracks.addAll(upNextQueue.subList(1, upNextQueue.size))
1232
- } else if (currentTemporaryType != TemporaryType.UP_NEXT) {
1233
- newQueueTracks.addAll(upNextQueue)
1234
- }
1235
-
1236
- // Add remaining original tracks — use currentTrackIndex (original playlist position)
1237
- if (currentTrackIndex + 1 < currentTracks.size) {
1238
- val remaining = currentTracks.subList(currentTrackIndex + 1, currentTracks.size)
1239
- newQueueTracks.addAll(remaining)
1240
- }
1241
-
1242
- // Create MediaItems for new tracks
1243
- val playlistId = currentPlaylistId ?: ""
1244
- val newMediaItems =
1245
- newQueueTracks.map { track ->
1246
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
1247
- track.toMediaItem(mediaId)
1248
- }
1249
-
1250
- // Remove all items after current in one batch (single timeline event vs N events)
1251
- if (player.mediaItemCount > currentIndex + 1) {
1252
- player.removeMediaItems(currentIndex + 1, player.mediaItemCount)
1253
- }
1254
-
1255
- // Add new items
1256
- player.addMediaItems(newMediaItems)
1257
- }
1258
-
1259
- /**
1260
- * Find a track by ID from current playlist or all playlists
1261
- */
1262
- private fun findTrackById(trackId: String): TrackItem? {
1263
- // First check current playlist
1264
- currentTracks.find { it.id == trackId }?.let { return it }
1265
-
1266
- // Then check all playlists
1267
- val allPlaylists = playlistManager.getAllPlaylists()
1268
- for (playlist in allPlaylists) {
1269
- playlist.tracks.find { it.id == trackId }?.let { return it }
1270
- }
1271
-
1272
- return null
1273
- }
1274
-
1275
- /**
1276
- * Determine what type of track is currently playing
1277
- */
1278
- private fun determineCurrentTemporaryType(): TemporaryType {
1279
- val currentItem = player.currentMediaItem ?: return TemporaryType.NONE
1280
- val trackId =
1281
- if (currentItem.mediaId.contains(':')) {
1282
- currentItem.mediaId.substring(currentItem.mediaId.indexOf(':') + 1)
1283
- } else {
1284
- currentItem.mediaId
1285
- }
1286
-
1287
- // Check if in playNext stack
1288
- if (playNextStack.any { it.id == trackId }) {
1289
- return TemporaryType.PLAY_NEXT
1290
- }
1291
-
1292
- // Check if in upNext queue
1293
- if (upNextQueue.any { it.id == trackId }) {
1294
- return TemporaryType.UP_NEXT
1295
- }
1296
-
1297
- return TemporaryType.NONE
1298
- }
1299
-
1300
- // Clean up resources
1301
144
  fun destroy() {
1302
- handler.post {
145
+ playerHandler.post {
1303
146
  androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
1304
- handler.removeCallbacks(progressUpdateRunnable)
1305
- playerListener?.let { player.removeListener(it) }
1306
- playerListener = null
1307
- }
1308
- }
1309
-
1310
- // Check if Android Auto is connected
1311
- fun isAndroidAutoConnected(): Boolean = isAndroidAutoConnected
1312
-
1313
- // Set the Android Auto media library structure from JSON
1314
- fun setAndroidAutoMediaLibrary(libraryJson: String) {
1315
- handler.post {
1316
- try {
1317
- val library = MediaLibraryParser.fromJson(libraryJson)
1318
- mediaLibraryManager.setMediaLibrary(library)
1319
- // Notify Android Auto to refresh
1320
- NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
1321
- NitroPlayerLogger.log("TrackPlayerCore", "✅ TrackPlayerCore: Android Auto media library set successfully")
1322
- } catch (e: Exception) {
1323
- NitroPlayerLogger.log("TrackPlayerCore") { "❌ TrackPlayerCore: Error setting media library - ${e.message}" }
1324
- e.printStackTrace()
1325
- }
1326
- }
1327
- }
1328
-
1329
- // Clear the Android Auto media library
1330
- fun clearAndroidAutoMediaLibrary() {
1331
- handler.post {
1332
- mediaLibraryManager.clear()
1333
- NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
1334
- }
1335
- }
1336
-
1337
- // Set volume (0-100 range, converted to 0.0-1.0 for ExoPlayer)
1338
- fun setVolume(volume: Double): Boolean =
1339
- if (::player.isInitialized) {
1340
- handler.post {
1341
- // Clamp volume to 0-100 range
1342
- val clampedVolume = volume.coerceIn(0.0, 100.0)
1343
- // Convert to 0.0-1.0 range for ExoPlayer
1344
- val normalizedVolume = (clampedVolume / 100.0).toFloat()
1345
- player.volume = normalizedVolume
1346
- NitroPlayerLogger.log("TrackPlayerCore") { "🔊 TrackPlayerCore: Volume set to $clampedVolume% (normalized: $normalizedVolume)" }
1347
- }
1348
- true
1349
- } else {
1350
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ TrackPlayerCore: Cannot set volume - player not initialized")
1351
- false
1352
- }
1353
-
1354
- // Add event listeners
1355
- fun addOnChangeTrackListener(callback: (TrackItem, Reason?) -> Unit) {
1356
- val box = WeakCallbackBox(WeakReference(this), callback)
1357
- onChangeTrackListeners.add(box)
1358
- }
1359
-
1360
- fun addOnPlaybackStateChangeListener(callback: (TrackPlayerState, Reason?) -> Unit) {
1361
- val box = WeakCallbackBox(WeakReference(this), callback)
1362
- onPlaybackStateChangeListeners.add(box)
1363
- }
1364
-
1365
- fun addOnSeekListener(callback: (Double, Double) -> Unit) {
1366
- val box = WeakCallbackBox(WeakReference(this), callback)
1367
- onSeekListeners.add(box)
1368
- }
1369
-
1370
- fun addOnPlaybackProgressChangeListener(callback: (Double, Double, Boolean?) -> Unit) {
1371
- val box = WeakCallbackBox(WeakReference(this), callback)
1372
- onPlaybackProgressChangeListeners.add(box)
1373
- }
1374
-
1375
- // Notification helpers with auto-cleanup
1376
- private fun notifyTrackChange(
1377
- track: TrackItem,
1378
- reason: Reason?,
1379
- ) {
1380
- val liveCallbacks =
1381
- synchronized(onChangeTrackListeners) {
1382
- onChangeTrackListeners.removeAll { !it.isAlive }
1383
- onChangeTrackListeners.map { it.callback }
1384
- }
1385
-
1386
- handler.post {
1387
- for (callback in liveCallbacks) {
1388
- try {
1389
- callback(track, reason)
1390
- } catch (e: Exception) {
1391
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Error in track change listener: ${e.message}" }
1392
- }
1393
- }
1394
- }
1395
- }
1396
-
1397
- private fun notifyPlaybackStateChange(
1398
- state: TrackPlayerState,
1399
- reason: Reason?,
1400
- ) {
1401
- val liveCallbacks =
1402
- synchronized(onPlaybackStateChangeListeners) {
1403
- onPlaybackStateChangeListeners.removeAll { !it.isAlive }
1404
- onPlaybackStateChangeListeners.map { it.callback }
1405
- }
1406
-
1407
- handler.post {
1408
- for (callback in liveCallbacks) {
1409
- try {
1410
- callback(state, reason)
1411
- } catch (e: Exception) {
1412
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Error in playback state listener: ${e.message}" }
1413
- }
1414
- }
1415
- }
1416
- }
1417
-
1418
- private fun notifySeek(
1419
- position: Double,
1420
- duration: Double,
1421
- ) {
1422
- val liveCallbacks =
1423
- synchronized(onSeekListeners) {
1424
- onSeekListeners.removeAll { !it.isAlive }
1425
- onSeekListeners.map { it.callback }
1426
- }
1427
-
1428
- handler.post {
1429
- for (callback in liveCallbacks) {
1430
- try {
1431
- callback(position, duration)
1432
- } catch (e: Exception) {
1433
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Error in seek listener: ${e.message}" }
1434
- }
1435
- }
1436
- }
1437
- }
1438
-
1439
- private var progressNotifyCounter = 0
1440
- private val progressCallbackScratch = ArrayList<(Double, Double, Boolean?) -> Unit>(4)
1441
-
1442
- private fun notifyPlaybackProgress(
1443
- position: Double,
1444
- duration: Double,
1445
- isPlaying: Boolean?,
1446
- ) {
1447
- progressCallbackScratch.clear()
1448
- synchronized(onPlaybackProgressChangeListeners) {
1449
- if (++progressNotifyCounter % 10 == 0) {
1450
- onPlaybackProgressChangeListeners.removeAll { !it.isAlive }
1451
- }
1452
- for (box in onPlaybackProgressChangeListeners) {
1453
- if (box.isAlive) progressCallbackScratch.add(box.callback)
1454
- }
1455
- }
1456
-
1457
- if (progressCallbackScratch.isEmpty()) return
1458
-
1459
- handler.post {
1460
- for (callback in progressCallbackScratch) {
1461
- try {
1462
- callback(position, duration, isPlaying)
1463
- } catch (e: Exception) {
1464
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Error in playback progress listener: ${e.message}" }
1465
- }
1466
- }
1467
- }
1468
- }
1469
-
1470
- /**
1471
- * Get the actual queue with temporary tracks
1472
- * Returns: [original_before_current] + [current] + [playNext_stack] + [upNext_queue] + [original_after_current]
1473
- */
1474
- fun getActualQueue(): List<TrackItem> {
1475
- // Called from Promise.async background thread
1476
- // Check if we're already on the main thread
1477
- if (android.os.Looper.myLooper() == handler.looper) {
1478
- return getActualQueueInternal()
1479
- }
1480
-
1481
- // Use CountDownLatch to wait for the result on the main thread
1482
- val latch = CountDownLatch(1)
1483
- var result: List<TrackItem>? = null
1484
-
1485
- handler.post {
1486
- try {
1487
- result = getActualQueueInternal()
1488
- } finally {
1489
- latch.countDown()
1490
- }
1491
- }
1492
-
1493
- try {
1494
- // Wait up to 5 seconds for the result
1495
- latch.await(5, TimeUnit.SECONDS)
1496
- } catch (e: InterruptedException) {
1497
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ TrackPlayerCore: Interrupted while waiting for actual queue")
1498
- }
1499
-
1500
- return result ?: emptyList()
1501
- }
1502
-
1503
- private fun getActualQueueInternal(): List<TrackItem> {
1504
- val capacity = currentTracks.size + playNextStack.size + upNextQueue.size
1505
- val queue = ArrayList<TrackItem>(capacity)
1506
-
1507
- if (!::player.isInitialized) return emptyList()
1508
-
1509
- val currentIndex = currentTrackIndex
1510
- if (currentIndex < 0) return emptyList()
1511
-
1512
- // Add tracks before current (original playlist)
1513
- // When a temp track is playing, include the original track at currentTrackIndex
1514
- // (it already played before the temp track started)
1515
- val beforeEnd =
1516
- if (currentTemporaryType != TemporaryType.NONE) {
1517
- minOf(currentIndex + 1, currentTracks.size)
1518
- } else {
1519
- currentIndex
1520
- }
1521
- if (beforeEnd > 0) {
1522
- queue.addAll(currentTracks.subList(0, beforeEnd))
1523
- }
1524
-
1525
- // Add current track (temp or original)
1526
- getCurrentTrack()?.let { queue.add(it) }
1527
-
1528
- // Add playNext stack (LIFO - most recently added plays first)
1529
- // Skip index 0 if current track is from playNext (it's already added as current)
1530
- if (currentTemporaryType == TemporaryType.PLAY_NEXT && playNextStack.size > 1) {
1531
- queue.addAll(playNextStack.subList(1, playNextStack.size))
1532
- } else if (currentTemporaryType != TemporaryType.PLAY_NEXT) {
1533
- queue.addAll(playNextStack)
1534
- }
1535
-
1536
- // Add upNext queue (in order, FIFO)
1537
- // Skip index 0 if current track is from upNext (it's already added as current)
1538
- if (currentTemporaryType == TemporaryType.UP_NEXT && upNextQueue.size > 1) {
1539
- queue.addAll(upNextQueue.subList(1, upNextQueue.size))
1540
- } else if (currentTemporaryType != TemporaryType.UP_NEXT) {
1541
- queue.addAll(upNextQueue)
1542
- }
1543
-
1544
- // Add remaining original tracks
1545
- if (currentIndex + 1 < currentTracks.size) {
1546
- queue.addAll(currentTracks.subList(currentIndex + 1, currentTracks.size))
1547
- }
1548
-
1549
- return queue
1550
- }
1551
-
1552
- // MARK: - Lazy URL Loading Support
1553
-
1554
- /**
1555
- * Update entire track objects and rebuild queue if needed
1556
- * Skips currently playing track to preserve gapless playback
1557
- */
1558
- fun updateTracks(tracks: List<TrackItem>) {
1559
- handler.post {
1560
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 updateTracks: ${tracks.size} updates" }
1561
-
1562
- // Get current track to decide how to handle it
1563
- val currentTrack = getCurrentTrack()
1564
- val currentTrackId = currentTrack?.id
1565
-
1566
- // Separate the current-track update (if any) from the rest
1567
- val currentTrackUpdate = if (currentTrackId != null) tracks.find { it.id == currentTrackId } else null
1568
- val currentTrackIsEmpty = currentTrack?.url.isNullOrEmpty()
1569
-
1570
- // Filter out current track and validate
1571
- val safeTracks =
1572
- tracks.filter { track ->
1573
- when {
1574
- track.id == currentTrackId && !currentTrackIsEmpty -> {
1575
- // Has a real URL already — skip to preserve gapless playback
1576
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Skipping update for currently playing track: ${track.id} (preserves gapless)" }
1577
- false
1578
- }
1579
-
1580
- track.id == currentTrackId && currentTrackIsEmpty -> {
1581
- // Empty URL — must not be playing, allow the update
1582
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 Updating current track with no URL: ${track.id}" }
1583
- track.url.isNotEmpty() // only include if the update actually provides a URL
1584
- }
1585
-
1586
- track.url.isEmpty() -> {
1587
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Skipping track with empty URL: ${track.id}" }
1588
- false
1589
- }
1590
-
1591
- else -> {
1592
- true
1593
- }
1594
- }
1595
- }
1596
-
1597
- if (safeTracks.isEmpty()) {
1598
- NitroPlayerLogger.log("TrackPlayerCore", "✅ No valid updates to apply")
1599
- return@post
1600
- }
1601
-
1602
- // Update in PlaylistManager
1603
- val affectedPlaylists = playlistManager.updateTracks(safeTracks)
1604
-
1605
- // If the current track was one of the updates (had no URL before), replace its
1606
- // MediaItem in ExoPlayer directly — rebuildQueueFromCurrentPosition skips index 0.
1607
- if (currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()) {
1608
- val exoIndex = player.currentMediaItemIndex
1609
- if (exoIndex >= 0) {
1610
- val playlistId = currentPlaylistId ?: ""
1611
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate.id}" else currentTrackUpdate.id
1612
- val newMediaItem = currentTrackUpdate.toMediaItem(mediaId)
1613
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 Replacing MediaItem at index $exoIndex for current track with resolved URL" }
1614
- player.replaceMediaItem(exoIndex, newMediaItem)
1615
- // If ExoPlayer was in an error/idle state waiting for a URI, re-prepare
1616
- if (player.playbackState == Player.STATE_IDLE) {
1617
- player.prepare()
1618
- }
1619
- }
147
+ playerHandler.removeCallbacks(progressUpdateRunnable)
148
+ if (::exo.isInitialized) {
149
+ playerListener?.let { exo.removeListener(it) }
1620
150
  }
1621
-
1622
- // Rebuild queue for other updated tracks if current playlist was affected
1623
- if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
1624
- NitroPlayerLogger.log("TrackPlayerCore") { "🔄 Rebuilding queue - ${affectedPlaylists[currentPlaylistId]} tracks updated in current playlist" }
1625
-
1626
- // PlaylistManager.updateTracks() creates a new Playlist via .copy(tracks = newTracks),
1627
- // so our currentTracks reference still points at the old list with empty URLs.
1628
- // Refresh it now so rebuildQueueFromCurrentPosition builds MediaItems with the
1629
- // resolved URLs, allowing ExoPlayer to pre-buffer the next track for gapless playback.
1630
- val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
1631
- if (refreshedPlaylist != null) {
1632
- currentTracks = refreshedPlaylist.tracks
1633
-
1634
- // Also reconcile any queued items that still reference old TrackItem instances
1635
- // from this playlist, so that gapless pre-buffering uses tracks with resolved URLs.
1636
- val updatedTrackById = currentTracks.associateBy { it.id }
1637
-
1638
- // Update playNextStack entries to point at the refreshed TrackItem objects.
1639
- playNextStack.forEachIndexed { index, track ->
1640
- val updated = updatedTrackById[track.id]
1641
- if (updated != null && updated !== track) {
1642
- playNextStack[index] = updated
1643
- }
1644
- }
1645
-
1646
- // Update upNextQueue entries to point at the refreshed TrackItem objects.
1647
- upNextQueue.forEachIndexed { index, track ->
1648
- val updated = updatedTrackById[track.id]
1649
- if (updated != null && updated !== track) {
1650
- upNextQueue[index] = updated
1651
- }
1652
- }
1653
- }
1654
-
1655
- // This method preserves current item and gapless buffering
1656
- rebuildQueueFromCurrentPosition()
1657
-
1658
- NitroPlayerLogger.log("TrackPlayerCore", "✅ Queue rebuilt, gapless playback preserved")
1659
- }
1660
-
1661
- NitroPlayerLogger.log("TrackPlayerCore") { "✅ Track updates complete - ${affectedPlaylists.size} playlists affected" }
1662
- }
1663
- }
1664
-
1665
- /**
1666
- * Get tracks by IDs from all playlists
1667
- */
1668
- fun getTracksById(trackIds: List<String>): List<TrackItem> {
1669
- if (android.os.Looper.myLooper() == handler.looper) {
1670
- return playlistManager.getTracksById(trackIds)
1671
- }
1672
-
1673
- val latch = CountDownLatch(1)
1674
- var result: List<TrackItem>? = null
1675
-
1676
- handler.post {
1677
- try {
1678
- result = playlistManager.getTracksById(trackIds)
1679
- } finally {
1680
- latch.countDown()
1681
- }
1682
- }
1683
-
1684
- try {
1685
- latch.await(5, TimeUnit.SECONDS)
1686
- } catch (e: InterruptedException) {
1687
- Thread.currentThread().interrupt()
1688
- }
1689
-
1690
- return result ?: emptyList()
1691
- }
1692
-
1693
- /**
1694
- * Get tracks needing URLs from current playlist
1695
- */
1696
- fun getTracksNeedingUrls(): List<TrackItem> {
1697
- if (android.os.Looper.myLooper() == handler.looper) {
1698
- return getTracksNeedingUrlsInternal()
1699
- }
1700
-
1701
- val latch = CountDownLatch(1)
1702
- var result: List<TrackItem>? = null
1703
-
1704
- handler.post {
1705
- try {
1706
- result = getTracksNeedingUrlsInternal()
1707
- } finally {
1708
- latch.countDown()
1709
- }
1710
- }
1711
-
1712
- try {
1713
- latch.await(5, TimeUnit.SECONDS)
1714
- } catch (e: InterruptedException) {
1715
- Thread.currentThread().interrupt()
1716
- }
1717
-
1718
- return result ?: emptyList()
1719
- }
1720
-
1721
- private fun getTracksNeedingUrlsInternal(): List<TrackItem> {
1722
- if (currentPlaylistId == null) return emptyList()
1723
-
1724
- val playlist = playlistManager.getPlaylist(currentPlaylistId!!)
1725
- return playlist?.tracks?.filter { it.url.isEmpty() } ?: emptyList()
1726
- }
1727
-
1728
- /**
1729
- * Get next N tracks from current position
1730
- */
1731
- fun getNextTracks(count: Int): List<TrackItem> {
1732
- if (android.os.Looper.myLooper() == handler.looper) {
1733
- return getNextTracksInternal(count)
1734
- }
1735
-
1736
- val latch = CountDownLatch(1)
1737
- var result: List<TrackItem>? = null
1738
-
1739
- handler.post {
1740
- try {
1741
- result = getNextTracksInternal(count)
1742
- } finally {
1743
- latch.countDown()
1744
- }
1745
- }
1746
-
1747
- try {
1748
- latch.await(5, TimeUnit.SECONDS)
1749
- } catch (e: InterruptedException) {
1750
- Thread.currentThread().interrupt()
1751
- }
1752
-
1753
- return result ?: emptyList()
1754
- }
1755
-
1756
- private fun getNextTracksInternal(count: Int): List<TrackItem> {
1757
- val actualQueue = getActualQueueInternal()
1758
- if (actualQueue.isEmpty()) return emptyList()
1759
-
1760
- val currentIndex = actualQueue.indexOfFirst { it.id == getCurrentTrack()?.id }
1761
- if (currentIndex == -1) return emptyList()
1762
-
1763
- val startIndex = currentIndex + 1
1764
- val endIndex = minOf(startIndex + count, actualQueue.size)
1765
-
1766
- return if (startIndex < actualQueue.size) {
1767
- actualQueue.subList(startIndex, endIndex)
1768
- } else {
1769
- emptyList()
1770
- }
1771
- }
1772
-
1773
- /**
1774
- * Get current track index in playlist
1775
- */
1776
- fun getCurrentTrackIndex(): Int {
1777
- if (android.os.Looper.myLooper() == handler.looper) {
1778
- return currentTrackIndex
1779
- }
1780
-
1781
- val latch = CountDownLatch(1)
1782
- var result = -1
1783
-
1784
- handler.post {
1785
- try {
1786
- result = currentTrackIndex
1787
- } finally {
1788
- latch.countDown()
1789
- }
1790
- }
1791
-
1792
- try {
1793
- latch.await(5, TimeUnit.SECONDS)
1794
- } catch (e: InterruptedException) {
1795
- Thread.currentThread().interrupt()
1796
- }
1797
-
1798
- return result
1799
- }
1800
-
1801
- /**
1802
- * Callback interface for tracks needing update
1803
- */
1804
- fun interface OnTracksNeedUpdateListener {
1805
- fun onTracksNeedUpdate(
1806
- tracks: List<TrackItem>,
1807
- lookahead: Int,
1808
- )
1809
- }
1810
-
1811
- // Add to class properties
1812
- private val onTracksNeedUpdateListeners = mutableListOf<OnTracksNeedUpdateListener>()
1813
-
1814
- /**
1815
- * Register listener for when tracks need update
1816
- */
1817
- fun addOnTracksNeedUpdateListener(listener: OnTracksNeedUpdateListener) {
1818
- handler.post {
1819
- onTracksNeedUpdateListeners.add(listener)
1820
- }
1821
- }
1822
-
1823
- /**
1824
- * Remove listener
1825
- */
1826
- fun removeOnTracksNeedUpdateListener(listener: OnTracksNeedUpdateListener) {
1827
- handler.post {
1828
- onTracksNeedUpdateListeners.removeAll { it == listener }
1829
- }
1830
- }
1831
-
1832
- /**
1833
- * Notify listeners that tracks need updating
1834
- * Called internally when moving to next track and upcoming tracks have empty URLs
1835
- */
1836
- private fun notifyTracksNeedUpdate(
1837
- tracks: List<TrackItem>,
1838
- lookahead: Int,
1839
- ) {
1840
- val liveCallbacks =
1841
- synchronized(onTracksNeedUpdateListeners) {
1842
- onTracksNeedUpdateListeners.toList()
1843
- }
1844
-
1845
- handler.post {
1846
- for (callback in liveCallbacks) {
1847
- try {
1848
- callback.onTracksNeedUpdate(tracks, lookahead)
1849
- } catch (e: Exception) {
1850
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ Error in onTracksNeedUpdate listener: ${e.message}" }
1851
- }
1852
- }
1853
- }
1854
- }
1855
-
1856
- /**
1857
- * Check if upcoming tracks need URLs and notify listeners
1858
- * Call this in onMediaItemTransition or after skipTo operations
1859
- */
1860
- private fun checkUpcomingTracksForUrls(lookahead: Int = 5) {
1861
- val upcomingTracks = if (currentTrackIndex < 0) {
1862
- // Playback hasn't started yet - check first N tracks from the loaded playlist
1863
- currentTracks.take(lookahead)
1864
- } else {
1865
- // Playback is active - check upcoming tracks
1866
- getNextTracksInternal(lookahead)
1867
- }
1868
-
1869
- // Always include the current track if it has no URL — it can't play without one
1870
- val currentTrack = getCurrentTrack()
1871
- val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
1872
- val candidateTracks = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
1873
-
1874
- val tracksNeedingUrls = candidateTracks.filter { it.url.isEmpty() }
1875
-
1876
- if (tracksNeedingUrls.isNotEmpty()) {
1877
- NitroPlayerLogger.log("TrackPlayerCore") { "⚠️ ${tracksNeedingUrls.size} upcoming tracks need URLs" }
1878
- notifyTracksNeedUpdate(tracksNeedingUrls, lookahead)
1879
- }
1880
- }
1881
-
1882
- fun setPlayBackSpeed(speed: Double) {
1883
- if (android.os.Looper.myLooper() == handler.looper) {
1884
- setPlayBackSpeedInternal(speed)
1885
- return
1886
- }
1887
- val latch = CountDownLatch(1)
1888
- handler.post {
1889
- try {
1890
- setPlayBackSpeedInternal(speed)
1891
- } finally {
1892
- latch.countDown()
1893
- }
1894
- }
1895
- try {
1896
- latch.await(5, TimeUnit.SECONDS)
1897
- } catch (e: InterruptedException) {
1898
- Thread.currentThread().interrupt()
1899
- }
1900
- }
1901
-
1902
- private fun setPlayBackSpeedInternal(speed: Double) {
1903
- if (::player.isInitialized) {
1904
- player.setPlaybackSpeed(speed.toFloat())
151
+ playerListener = null
1905
152
  }
153
+ scope.cancel()
154
+ playerThread.quitSafely()
1906
155
  }
1907
156
 
1908
- fun getPlayBackSpeed(): Double {
1909
- if (android.os.Looper.myLooper() == handler.looper) {
1910
- return getPlayBackSpeedInternal()
1911
- }
1912
- val latch = CountDownLatch(1)
1913
- var result = 1.0
1914
- handler.post {
1915
- try {
1916
- result = getPlayBackSpeedInternal()
1917
- } finally {
1918
- latch.countDown()
1919
- }
1920
- }
1921
- try {
1922
- latch.await(5, TimeUnit.SECONDS)
1923
- } catch (e: InterruptedException) {
1924
- Thread.currentThread().interrupt()
1925
- }
1926
- return result
1927
- }
157
+ // ── Simple read-only accessors ─────────────────────────────────────────
1928
158
 
1929
- private fun getPlayBackSpeedInternal(): Double =
1930
- if (::player.isInitialized) {
1931
- player.playbackParameters.speed.toDouble()
1932
- } else {
1933
- 1.0
1934
- }
159
+ fun isAndroidAutoConnected(): Boolean = isAndroidAutoConnectedField
160
+ fun getCurrentPlaylistId(): String? = currentPlaylistId
161
+ fun getPlaylistManager(): PlaylistManager = playlistManager
162
+ fun getAllPlaylists(): List<com.margelo.nitro.nitroplayer.playlist.Playlist> =
163
+ playlistManager.getAllPlaylists()
164
+
165
+ // ── Listener add/remove (returns stable ID for cleanup) ───────────────
166
+
167
+ fun addOnChangeTrackListener(cb: (TrackItem, Reason?) -> Unit): Long =
168
+ onChangeTrackListeners.add(cb)
169
+ fun removeOnChangeTrackListener(id: Long): Boolean =
170
+ onChangeTrackListeners.remove(id)
171
+
172
+ fun addOnPlaybackStateChangeListener(cb: (TrackPlayerState, Reason?) -> Unit): Long =
173
+ onPlaybackStateChangeListeners.add(cb)
174
+ fun removeOnPlaybackStateChangeListener(id: Long): Boolean =
175
+ onPlaybackStateChangeListeners.remove(id)
176
+
177
+ fun addOnSeekListener(cb: (Double, Double) -> Unit): Long =
178
+ onSeekListeners.add(cb)
179
+ fun removeOnSeekListener(id: Long): Boolean =
180
+ onSeekListeners.remove(id)
181
+
182
+ fun addOnPlaybackProgressChangeListener(cb: (Double, Double, Boolean?) -> Unit): Long =
183
+ onProgressListeners.add(cb)
184
+ fun removeOnPlaybackProgressChangeListener(id: Long): Boolean =
185
+ onProgressListeners.remove(id)
186
+
187
+ fun addOnTracksNeedUpdateListener(cb: (List<TrackItem>, Int) -> Unit): Long =
188
+ onTracksNeedUpdateListeners.add(cb)
189
+ fun removeOnTracksNeedUpdateListener(id: Long): Boolean =
190
+ onTracksNeedUpdateListeners.remove(id)
191
+
192
+ fun addOnTemporaryQueueChangeListener(cb: (List<TrackItem>, List<TrackItem>) -> Unit): Long =
193
+ onTemporaryQueueChangeListeners.add(cb)
194
+ fun removeOnTemporaryQueueChangeListener(id: Long): Boolean =
195
+ onTemporaryQueueChangeListeners.remove(id)
196
+
197
+ fun addOnAndroidAutoConnectionListener(cb: (Boolean) -> Unit): Long =
198
+ onAndroidAutoConnectionListeners.add(cb)
199
+ fun removeOnAndroidAutoConnectionListener(id: Long): Boolean =
200
+ onAndroidAutoConnectionListeners.remove(id)
1935
201
  }