react-native-nitro-player 0.7.0 → 0.7.1-alpha.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.
- package/README.md +47 -46
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +179 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +170 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +150 -135
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
- package/ios/HybridDownloadManager.swift +32 -26
- package/ios/HybridEqualizer.swift +48 -35
- package/ios/HybridTrackPlayer.swift +127 -102
- package/ios/core/ListenerRegistry.swift +60 -0
- package/ios/core/TrackPlayerCore.swift +130 -2356
- package/ios/core/TrackPlayerListener.swift +395 -0
- package/ios/core/TrackPlayerNotify.swift +52 -0
- package/ios/core/TrackPlayerPlayback.swift +274 -0
- package/ios/core/TrackPlayerQueue.swift +221 -0
- package/ios/core/TrackPlayerQueueBuild.swift +493 -0
- package/ios/core/TrackPlayerTempQueue.swift +167 -0
- package/ios/core/TrackPlayerUrlLoader.swift +169 -0
- package/ios/equalizer/EqualizerCore.swift +63 -123
- package/ios/media/MediaSessionManager.swift +32 -49
- package/ios/playlist/PlaylistManager.swift +2 -9
- package/ios/queue/HybridPlayerQueue.swift +69 -66
- package/lib/hooks/useDownloadedTracks.js +16 -13
- package/lib/hooks/useEqualizer.d.ts +4 -4
- package/lib/hooks/useEqualizer.js +22 -17
- package/lib/hooks/useEqualizerPresets.d.ts +3 -3
- package/lib/hooks/useEqualizerPresets.js +12 -18
- package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
- package/lib/specs/AudioDevices.nitro.d.ts +2 -2
- package/lib/specs/DownloadManager.nitro.d.ts +10 -10
- package/lib/specs/Equalizer.nitro.d.ts +10 -10
- package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
- package/lib/types/EqualizerTypes.d.ts +3 -3
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +31 -6
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +2 -2
- package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +16 -3
- package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +1 -1
- package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +154 -44
- package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +10 -10
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +130 -34
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +9 -9
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +115 -24
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +8 -8
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +243 -24
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +16 -8
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +3 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +2 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +10 -10
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +10 -9
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +9 -8
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +45 -8
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
- package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
- package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
- package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
- package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
- package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
- package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
- package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
- package/package.json +5 -5
- package/src/hooks/useDownloadedTracks.ts +17 -13
- package/src/hooks/useEqualizer.ts +26 -21
- package/src/hooks/useEqualizerPresets.ts +15 -21
- package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
- package/src/specs/AudioDevices.nitro.ts +2 -2
- package/src/specs/DownloadManager.nitro.ts +10 -10
- package/src/specs/Equalizer.nitro.ts +10 -10
- package/src/specs/TrackPlayer.nitro.ts +52 -16
- package/src/types/EqualizerTypes.ts +17 -13
|
@@ -1,127 +1,112 @@
|
|
|
1
|
-
@file:Suppress("ktlint:standard:max-line-length"
|
|
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.
|
|
7
|
-
import
|
|
8
|
-
import
|
|
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
|
|
34
|
-
import
|
|
35
|
-
import
|
|
36
|
-
import
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
540
|
-
if (mediaItem == null) return null
|
|
128
|
+
// ── Coroutine bridge to player thread ──────────────────────────────────
|
|
541
129
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
playerHandler.post {
|
|
1303
146
|
androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
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
|
}
|