react-native-nitro-player 1.6.0 → 1.7.0-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 +6 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
- package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
- package/android/src/main/res/values/strings.xml +7 -0
- package/ios/HybridDownloadManager.swift +13 -3
- package/ios/HybridTrackPlayer.swift +4 -2
- package/ios/core/TrackPlayerCast.swift +32 -1
- package/ios/core/TrackPlayerCore.swift +19 -3
- package/ios/core/TrackPlayerListener.swift +15 -16
- package/ios/core/TrackPlayerNotify.swift +11 -11
- package/ios/core/TrackPlayerPlayback.swift +54 -16
- package/ios/core/TrackPlayerQueueBuild.swift +14 -2
- package/ios/core/TrackPlayerTempQueue.swift +1 -0
- package/ios/core/TrackPlayerUrlLoader.swift +11 -3
- package/ios/download/DownloadDatabase.swift +16 -5
- package/ios/download/DownloadFileManager.swift +12 -15
- package/ios/download/DownloadManagerCore.swift +61 -16
- package/ios/equalizer/EqualizerCore.swift +37 -9
- package/ios/media/MediaSessionManager.swift +89 -4
- package/ios/playlist/PlaylistManager.swift +47 -46
- package/ios/queue/HybridPlayerQueue.swift +28 -7
- package/lib/hooks/callbackManager.d.ts +9 -0
- package/lib/hooks/callbackManager.js +33 -0
- package/lib/hooks/useActualQueue.js +18 -6
- package/lib/hooks/useAndroidAutoConnection.js +2 -4
- package/lib/hooks/useAudioDevices.js +21 -24
- package/lib/hooks/useDownloadActions.js +38 -16
- package/lib/hooks/useDownloadProgress.js +20 -14
- package/lib/hooks/useDownloadStorage.js +1 -1
- package/lib/hooks/useNowPlaying.js +32 -6
- package/lib/hooks/useOnSeek.js +2 -2
- package/lib/hooks/usePlaylist.js +6 -15
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +44 -0
- package/src/hooks/useActualQueue.ts +18 -6
- package/src/hooks/useAndroidAutoConnection.ts +6 -6
- package/src/hooks/useAudioDevices.ts +21 -24
- package/src/hooks/useDownloadActions.ts +56 -26
- package/src/hooks/useDownloadProgress.ts +22 -19
- package/src/hooks/useDownloadStorage.ts +4 -3
- package/src/hooks/useNowPlaying.ts +34 -6
- package/src/hooks/useOnSeek.ts +2 -2
- package/src/hooks/usePlaylist.ts +6 -14
- package/src/specs/TrackPlayer.nitro.ts +1 -2
- package/src/types/PlayerQueue.ts +8 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.media
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import androidx.media3.common.Player
|
|
5
|
+
import androidx.media3.common.util.UnstableApi
|
|
6
|
+
import androidx.media3.session.CommandButton
|
|
4
7
|
import androidx.media3.session.MediaSession
|
|
8
|
+
import com.margelo.nitro.nitroplayer.R
|
|
5
9
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
6
10
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
7
11
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
12
|
+
import java.text.NumberFormat
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Thin wrapper around a [MediaSession] owned by the playback service.
|
|
@@ -12,11 +17,16 @@ import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
|
12
17
|
* No longer creates the session, notification channel, or manages service
|
|
13
18
|
* start/stop — the service handles all of that automatically per Media3 docs.
|
|
14
19
|
*/
|
|
20
|
+
@UnstableApi
|
|
15
21
|
class MediaSessionManager(
|
|
16
22
|
private val context: Context,
|
|
17
23
|
session: MediaSession,
|
|
18
24
|
private val playlistManager: PlaylistManager,
|
|
19
25
|
) {
|
|
26
|
+
private companion object {
|
|
27
|
+
const val DEFAULT_REMOTE_SKIP_INTERVAL_MS = 15_000L
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
private var trackPlayerCore: TrackPlayerCore? = null
|
|
21
31
|
|
|
22
32
|
fun setTrackPlayerCore(core: TrackPlayerCore) {
|
|
@@ -32,15 +42,26 @@ class MediaSessionManager(
|
|
|
32
42
|
private var androidAutoEnabled: Boolean = false
|
|
33
43
|
private var carPlayEnabled: Boolean = false
|
|
34
44
|
private var showInNotification: Boolean = true
|
|
45
|
+
private var remoteSkipForwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
46
|
+
private var remoteSkipBackwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
47
|
+
|
|
48
|
+
init {
|
|
49
|
+
updateMediaButtonPreferences()
|
|
50
|
+
}
|
|
35
51
|
|
|
36
52
|
fun configure(
|
|
37
53
|
androidAutoEnabled: Boolean?,
|
|
38
54
|
carPlayEnabled: Boolean?,
|
|
39
55
|
showInNotification: Boolean?,
|
|
56
|
+
remoteSkipForwardIntervalMs: Long,
|
|
57
|
+
remoteSkipBackwardIntervalMs: Long,
|
|
40
58
|
) {
|
|
41
59
|
androidAutoEnabled?.let { this.androidAutoEnabled = it }
|
|
42
60
|
carPlayEnabled?.let { this.carPlayEnabled = it }
|
|
43
61
|
showInNotification?.let { this.showInNotification = it }
|
|
62
|
+
this.remoteSkipForwardIntervalMs = remoteSkipForwardIntervalMs
|
|
63
|
+
this.remoteSkipBackwardIntervalMs = remoteSkipBackwardIntervalMs
|
|
64
|
+
updateMediaButtonPreferences()
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
fun onTrackChanged(track: TrackItem?) {
|
|
@@ -55,4 +76,82 @@ class MediaSessionManager(
|
|
|
55
76
|
// Service owns the session — just null out our reference
|
|
56
77
|
mediaSession = null
|
|
57
78
|
}
|
|
79
|
+
|
|
80
|
+
private fun updateMediaButtonPreferences() {
|
|
81
|
+
val session = mediaSession ?: return
|
|
82
|
+
// Preferences replace Media3's default layout — prev/next must be declared or they vanish
|
|
83
|
+
val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
|
|
84
|
+
session.setMediaButtonPreferences(
|
|
85
|
+
listOf(previousTrackButton(), skipBackButton(), skipForwardButton(), nextTrackButton()) + preserved,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private fun previousTrackButton(): CommandButton =
|
|
90
|
+
CommandButton
|
|
91
|
+
.Builder(CommandButton.ICON_PREVIOUS)
|
|
92
|
+
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
|
93
|
+
.setDisplayName(context.getString(R.string.nitro_player_previous_track))
|
|
94
|
+
.build()
|
|
95
|
+
|
|
96
|
+
private fun nextTrackButton(): CommandButton =
|
|
97
|
+
CommandButton
|
|
98
|
+
.Builder(CommandButton.ICON_NEXT)
|
|
99
|
+
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
|
|
100
|
+
.setDisplayName(context.getString(R.string.nitro_player_next_track))
|
|
101
|
+
.build()
|
|
102
|
+
|
|
103
|
+
private fun skipBackButton(): CommandButton =
|
|
104
|
+
CommandButton
|
|
105
|
+
.Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
|
|
106
|
+
.setPlayerCommand(Player.COMMAND_SEEK_BACK)
|
|
107
|
+
.setDisplayName(
|
|
108
|
+
context.getString(
|
|
109
|
+
R.string.nitro_player_skip_back_seconds,
|
|
110
|
+
displaySeconds(remoteSkipBackwardIntervalMs),
|
|
111
|
+
),
|
|
112
|
+
).build()
|
|
113
|
+
|
|
114
|
+
private fun skipForwardButton(): CommandButton =
|
|
115
|
+
CommandButton
|
|
116
|
+
.Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
|
|
117
|
+
.setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
|
|
118
|
+
.setDisplayName(
|
|
119
|
+
context.getString(
|
|
120
|
+
R.string.nitro_player_skip_forward_seconds,
|
|
121
|
+
displaySeconds(remoteSkipForwardIntervalMs),
|
|
122
|
+
),
|
|
123
|
+
).build()
|
|
124
|
+
|
|
125
|
+
private fun CommandButton.isManagedButton(): Boolean =
|
|
126
|
+
playerCommand == Player.COMMAND_SEEK_BACK ||
|
|
127
|
+
playerCommand == Player.COMMAND_SEEK_FORWARD ||
|
|
128
|
+
playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS ||
|
|
129
|
+
playerCommand == Player.COMMAND_SEEK_TO_NEXT
|
|
130
|
+
|
|
131
|
+
private fun skipBackIcon(intervalMs: Long): Int =
|
|
132
|
+
when (intervalMs) {
|
|
133
|
+
5_000L -> CommandButton.ICON_SKIP_BACK_5
|
|
134
|
+
10_000L -> CommandButton.ICON_SKIP_BACK_10
|
|
135
|
+
15_000L -> CommandButton.ICON_SKIP_BACK_15
|
|
136
|
+
30_000L -> CommandButton.ICON_SKIP_BACK_30
|
|
137
|
+
else -> CommandButton.ICON_SKIP_BACK
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private fun skipForwardIcon(intervalMs: Long): Int =
|
|
141
|
+
when (intervalMs) {
|
|
142
|
+
5_000L -> CommandButton.ICON_SKIP_FORWARD_5
|
|
143
|
+
10_000L -> CommandButton.ICON_SKIP_FORWARD_10
|
|
144
|
+
15_000L -> CommandButton.ICON_SKIP_FORWARD_15
|
|
145
|
+
30_000L -> CommandButton.ICON_SKIP_FORWARD_30
|
|
146
|
+
else -> CommandButton.ICON_SKIP_FORWARD
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun displaySeconds(intervalMs: Long): String =
|
|
150
|
+
NumberFormat
|
|
151
|
+
.getNumberInstance()
|
|
152
|
+
.apply {
|
|
153
|
+
maximumFractionDigits = 3
|
|
154
|
+
minimumFractionDigits = 0
|
|
155
|
+
isGroupingUsed = false
|
|
156
|
+
}.format(intervalMs / 1000.0)
|
|
58
157
|
}
|
|
@@ -107,7 +107,13 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
107
107
|
private fun initCast() {
|
|
108
108
|
try {
|
|
109
109
|
val castContext = CastContext.getSharedInstance(this)
|
|
110
|
-
val castPlayer =
|
|
110
|
+
val castPlayer =
|
|
111
|
+
CastPlayer(
|
|
112
|
+
castContext,
|
|
113
|
+
NitroMediaItemConverter(),
|
|
114
|
+
ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS,
|
|
115
|
+
ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS,
|
|
116
|
+
)
|
|
111
117
|
castSessionController =
|
|
112
118
|
CastSessionController(
|
|
113
119
|
castContext = castContext,
|
|
@@ -28,7 +28,7 @@ class PlaylistManager private constructor(
|
|
|
28
28
|
) {
|
|
29
29
|
private val playlists = ConcurrentHashMap<String, Playlist>()
|
|
30
30
|
private val listeners = CopyOnWriteArrayList<(List<Playlist>, QueueOperation?) -> Unit>()
|
|
31
|
-
private val playlistListeners =
|
|
31
|
+
private val playlistListeners = ConcurrentHashMap<String, CopyOnWriteArrayList<(Playlist, QueueOperation?) -> Unit>>()
|
|
32
32
|
private var currentPlaylistId: String? = null
|
|
33
33
|
|
|
34
34
|
private val saveScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
@@ -59,7 +59,8 @@ class PlaylistManager private constructor(
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
init {
|
|
62
|
-
//
|
|
62
|
+
// Synchronous so no mutation can race a late async load and be clobbered
|
|
63
|
+
loadPlaylistsFromFile()
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/**
|
|
@@ -75,10 +76,7 @@ class PlaylistManager private constructor(
|
|
|
75
76
|
|
|
76
77
|
playlists[id] = playlist
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
|
|
80
|
-
scheduleSave()
|
|
81
|
-
}
|
|
79
|
+
scheduleSave()
|
|
82
80
|
notifyPlaylistsChanged(QueueOperation.ADD)
|
|
83
81
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
|
|
84
82
|
|
|
@@ -96,10 +94,7 @@ class PlaylistManager private constructor(
|
|
|
96
94
|
currentPlaylistId = null
|
|
97
95
|
}
|
|
98
96
|
playlistListeners.remove(playlistId)
|
|
99
|
-
|
|
100
|
-
if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
|
|
101
|
-
scheduleSave()
|
|
102
|
-
}
|
|
97
|
+
scheduleSave()
|
|
103
98
|
notifyPlaylistsChanged(QueueOperation.REMOVE)
|
|
104
99
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
|
|
105
100
|
return true
|
|
@@ -117,18 +112,18 @@ class PlaylistManager private constructor(
|
|
|
117
112
|
description: String? = null,
|
|
118
113
|
artwork: String? = null,
|
|
119
114
|
): Boolean {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
115
|
+
// computeIfPresent: read-copy-put from two threads loses one edit
|
|
116
|
+
val updated =
|
|
117
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
118
|
+
playlist.copy(
|
|
119
|
+
name = name ?: playlist.name,
|
|
120
|
+
description = description ?: playlist.description,
|
|
121
|
+
artwork = artwork ?: playlist.artwork,
|
|
122
|
+
)
|
|
123
|
+
} != null
|
|
124
|
+
if (!updated) return false
|
|
125
|
+
|
|
126
|
+
scheduleSave()
|
|
132
127
|
notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
|
|
133
128
|
notifyPlaylistsChanged(QueueOperation.UPDATE)
|
|
134
129
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
|
|
@@ -154,19 +149,19 @@ class PlaylistManager private constructor(
|
|
|
154
149
|
track: TrackItem,
|
|
155
150
|
index: Int? = null,
|
|
156
151
|
): Boolean {
|
|
157
|
-
val
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
152
|
+
val added =
|
|
153
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
154
|
+
val tracks = playlist.tracks.toMutableList()
|
|
155
|
+
if (index != null && index >= 0 && index <= tracks.size) {
|
|
156
|
+
tracks.add(index, track)
|
|
157
|
+
} else {
|
|
158
|
+
tracks.add(track)
|
|
159
|
+
}
|
|
160
|
+
playlist.copy(tracks = tracks)
|
|
161
|
+
} != null
|
|
162
|
+
if (!added) return false
|
|
165
163
|
|
|
166
|
-
|
|
167
|
-
if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
|
|
168
|
-
scheduleSave()
|
|
169
|
-
}
|
|
164
|
+
scheduleSave()
|
|
170
165
|
notifyPlaylistChanged(playlistId, QueueOperation.ADD)
|
|
171
166
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
|
|
172
167
|
return true
|
|
@@ -180,19 +175,19 @@ class PlaylistManager private constructor(
|
|
|
180
175
|
tracks: List<TrackItem>,
|
|
181
176
|
index: Int? = null,
|
|
182
177
|
): Boolean {
|
|
183
|
-
val
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
178
|
+
val added =
|
|
179
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
180
|
+
val currentTracks = playlist.tracks.toMutableList()
|
|
181
|
+
if (index != null && index >= 0 && index <= currentTracks.size) {
|
|
182
|
+
currentTracks.addAll(index, tracks)
|
|
183
|
+
} else {
|
|
184
|
+
currentTracks.addAll(tracks)
|
|
185
|
+
}
|
|
186
|
+
playlist.copy(tracks = currentTracks)
|
|
187
|
+
} != null
|
|
188
|
+
if (!added) return false
|
|
191
189
|
|
|
192
|
-
|
|
193
|
-
if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
|
|
194
|
-
scheduleSave()
|
|
195
|
-
}
|
|
190
|
+
scheduleSave()
|
|
196
191
|
notifyPlaylistChanged(playlistId, QueueOperation.ADD)
|
|
197
192
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
|
|
198
193
|
return true
|
|
@@ -205,11 +200,15 @@ class PlaylistManager private constructor(
|
|
|
205
200
|
playlistId: String,
|
|
206
201
|
trackId: String,
|
|
207
202
|
): Boolean {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
var removed = false
|
|
204
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
205
|
+
val tracks = playlist.tracks.toMutableList()
|
|
206
|
+
if (tracks.removeAll { it.id == trackId }) {
|
|
207
|
+
removed = true
|
|
208
|
+
playlist.copy(tracks = tracks)
|
|
209
|
+
} else {
|
|
210
|
+
playlist
|
|
211
|
+
}
|
|
213
212
|
}
|
|
214
213
|
|
|
215
214
|
if (removed) {
|
|
@@ -229,15 +228,20 @@ class PlaylistManager private constructor(
|
|
|
229
228
|
trackId: String,
|
|
230
229
|
newIndex: Int,
|
|
231
230
|
): Boolean {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
231
|
+
var reordered = false
|
|
232
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
233
|
+
val tracks = playlist.tracks.toMutableList()
|
|
234
|
+
val oldIndex = tracks.indexOfFirst { it.id == trackId }
|
|
235
|
+
if (oldIndex < 0 || newIndex < 0 || newIndex >= tracks.size) {
|
|
236
|
+
playlist
|
|
237
|
+
} else {
|
|
238
|
+
val track = tracks.removeAt(oldIndex)
|
|
239
|
+
tracks.add(newIndex, track)
|
|
240
|
+
reordered = true
|
|
241
|
+
playlist.copy(tracks = tracks)
|
|
242
|
+
}
|
|
237
243
|
}
|
|
238
|
-
|
|
239
|
-
tracks.add(newIndex, track)
|
|
240
|
-
playlists[playlistId] = playlist.copy(tracks = tracks)
|
|
244
|
+
if (!reordered) return false
|
|
241
245
|
|
|
242
246
|
scheduleSave()
|
|
243
247
|
notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
|
|
@@ -255,17 +259,19 @@ class PlaylistManager private constructor(
|
|
|
255
259
|
val tracksMap = tracks.associateBy { it.id }
|
|
256
260
|
val affectedPlaylists = mutableMapOf<String, Int>()
|
|
257
261
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
.map { track ->
|
|
262
|
+
for (playlistId in playlists.keys) {
|
|
263
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
264
|
+
var updateCount = 0
|
|
265
|
+
val newTracks =
|
|
266
|
+
playlist.tracks.map { track ->
|
|
263
267
|
tracksMap[track.id]?.also { updateCount++ } ?: track
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
}
|
|
269
|
+
if (updateCount > 0) {
|
|
270
|
+
affectedPlaylists[playlistId] = updateCount
|
|
271
|
+
playlist.copy(tracks = newTracks.toMutableList())
|
|
272
|
+
} else {
|
|
273
|
+
playlist
|
|
274
|
+
}
|
|
269
275
|
}
|
|
270
276
|
}
|
|
271
277
|
|
|
@@ -336,11 +342,19 @@ class PlaylistManager private constructor(
|
|
|
336
342
|
playlistId: String,
|
|
337
343
|
listener: (Playlist, QueueOperation?) -> Unit,
|
|
338
344
|
): () -> Unit {
|
|
339
|
-
val playlistListeners = playlistListeners.
|
|
345
|
+
val playlistListeners = playlistListeners.computeIfAbsent(playlistId) { CopyOnWriteArrayList() }
|
|
340
346
|
playlistListeners.add(listener)
|
|
341
347
|
return { playlistListeners.remove(listener) }
|
|
342
348
|
}
|
|
343
349
|
|
|
350
|
+
private val anyPlaylistListeners = CopyOnWriteArrayList<(Playlist, QueueOperation?) -> Unit>()
|
|
351
|
+
|
|
352
|
+
/** Fires for changes to ANY playlist, including ones created after registration. */
|
|
353
|
+
fun addAnyPlaylistChangeListener(listener: (Playlist, QueueOperation?) -> Unit): () -> Unit {
|
|
354
|
+
anyPlaylistListeners.add(listener)
|
|
355
|
+
return { anyPlaylistListeners.remove(listener) }
|
|
356
|
+
}
|
|
357
|
+
|
|
344
358
|
private fun notifyPlaylistsChanged(operation: QueueOperation?) {
|
|
345
359
|
listeners.forEach { it(playlists.values.toList(), operation) }
|
|
346
360
|
}
|
|
@@ -351,6 +365,7 @@ class PlaylistManager private constructor(
|
|
|
351
365
|
) {
|
|
352
366
|
val playlist = playlists[playlistId] ?: return
|
|
353
367
|
playlistListeners[playlistId]?.forEach { it(playlist, operation) }
|
|
368
|
+
anyPlaylistListeners.forEach { it(playlist, operation) }
|
|
354
369
|
}
|
|
355
370
|
|
|
356
371
|
// MARK: - Persistence
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<resources>
|
|
3
|
+
<string name="nitro_player_skip_back_seconds">Skip back %1$s seconds</string>
|
|
4
|
+
<string name="nitro_player_skip_forward_seconds">Skip forward %1$s seconds</string>
|
|
5
|
+
<string name="nitro_player_previous_track">Previous track</string>
|
|
6
|
+
<string name="nitro_player_next_track">Next track</string>
|
|
7
|
+
</resources>
|
|
@@ -213,20 +213,30 @@ final class HybridDownloadManager: HybridDownloadManagerSpec {
|
|
|
213
213
|
|
|
214
214
|
// MARK: - Event Callbacks
|
|
215
215
|
|
|
216
|
+
private var progressCallbackIds: [UUID] = []
|
|
217
|
+
private var stateChangeCallbackIds: [UUID] = []
|
|
218
|
+
private var completeCallbackIds: [UUID] = []
|
|
219
|
+
|
|
216
220
|
func onDownloadProgress(callback: @escaping (DownloadProgress) -> Void) throws {
|
|
217
221
|
NitroPlayerLogger.log("HybridDownloadManager", "onDownloadProgress callback registered")
|
|
218
|
-
core.addProgressCallback(callback)
|
|
222
|
+
progressCallbackIds.append(core.addProgressCallback(callback))
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
func onDownloadStateChange(
|
|
222
226
|
callback: @escaping (String, String, DownloadState, DownloadError?) -> Void
|
|
223
227
|
) throws {
|
|
224
228
|
NitroPlayerLogger.log("HybridDownloadManager", "onDownloadStateChange callback registered")
|
|
225
|
-
core.addStateChangeCallback(callback)
|
|
229
|
+
stateChangeCallbackIds.append(core.addStateChangeCallback(callback))
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
func onDownloadComplete(callback: @escaping (DownloadedTrack) -> Void) throws {
|
|
229
233
|
NitroPlayerLogger.log("HybridDownloadManager", "onDownloadComplete callback registered")
|
|
230
|
-
core.addCompleteCallback(callback)
|
|
234
|
+
completeCallbackIds.append(core.addCompleteCallback(callback))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
deinit {
|
|
238
|
+
progressCallbackIds.forEach { core.removeProgressCallback(id: $0) }
|
|
239
|
+
stateChangeCallbackIds.forEach { core.removeStateChangeCallback(id: $0) }
|
|
240
|
+
completeCallbackIds.forEach { core.removeCompleteCallback(id: $0) }
|
|
231
241
|
}
|
|
232
242
|
}
|
|
@@ -98,7 +98,9 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
98
98
|
androidAutoEnabled: config.androidAutoEnabled,
|
|
99
99
|
carPlayEnabled: config.carPlayEnabled,
|
|
100
100
|
showInNotification: config.showInNotification,
|
|
101
|
-
lookaheadCount: config.lookaheadCount.map { Int($0) }
|
|
101
|
+
lookaheadCount: config.lookaheadCount.map { Int($0) },
|
|
102
|
+
remoteSkipForwardInterval: config.remoteSkipForwardInterval,
|
|
103
|
+
remoteSkipBackwardInterval: config.remoteSkipBackwardInterval
|
|
102
104
|
)
|
|
103
105
|
}
|
|
104
106
|
}
|
|
@@ -137,7 +139,7 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
137
139
|
|
|
138
140
|
// MARK: - Playback speed
|
|
139
141
|
func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
|
|
140
|
-
|
|
142
|
+
enqueueThrowing { try self.core.setPlaybackSpeedOnQueue(speed) }
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
func getPlaybackSpeed() throws -> Promise<Double> {
|
|
@@ -67,13 +67,44 @@ extension TrackPlayerCore {
|
|
|
67
67
|
guard let self else { return }
|
|
68
68
|
guard self.isCasting else { return }
|
|
69
69
|
let resumePosition = self.castManager?.lastKnownRemotePosition ?? 0
|
|
70
|
+
let remoteTrackId = self.castManager?.currentRemoteTrackId
|
|
70
71
|
self.isCasting = false
|
|
71
72
|
|
|
72
73
|
let index = self.currentTrackIndex
|
|
73
74
|
guard index >= 0 && index < self.currentTracks.count else { return }
|
|
74
75
|
|
|
76
|
+
// rebuildQueueFromPlaylistIndex wipes the temp lists — preserve them
|
|
77
|
+
let savedPlayNext = self.playNextStack
|
|
78
|
+
let savedUpNext = self.upNextQueue
|
|
79
|
+
|
|
75
80
|
_ = self.rebuildQueueFromPlaylistIndex(index: index)
|
|
76
|
-
|
|
81
|
+
|
|
82
|
+
if !savedPlayNext.isEmpty || !savedUpNext.isEmpty {
|
|
83
|
+
self.playNextStack = savedPlayNext
|
|
84
|
+
self.upNextQueue = savedUpNext
|
|
85
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
86
|
+
self.notifyTemporaryQueueChange()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If the receiver was playing the head temp track, make it current again
|
|
90
|
+
// (mirrors skipToNext: remove from its list before it becomes current)
|
|
91
|
+
if let target = remoteTrackId, target != self.player?.currentItem?.trackId {
|
|
92
|
+
if self.playNextStack.first?.id == target {
|
|
93
|
+
self.playNextStack.removeFirst()
|
|
94
|
+
self.player?.advanceToNextItem()
|
|
95
|
+
self.currentTemporaryType = .playNext
|
|
96
|
+
self.notifyTemporaryQueueChange()
|
|
97
|
+
} else if self.playNextStack.isEmpty, self.upNextQueue.first?.id == target {
|
|
98
|
+
self.upNextQueue.removeFirst()
|
|
99
|
+
self.player?.advanceToNextItem()
|
|
100
|
+
self.currentTemporaryType = .upNext
|
|
101
|
+
self.notifyTemporaryQueueChange()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Only seek when the local current track is the one that was playing remotely
|
|
106
|
+
if resumePosition.isFinite && resumePosition > 0,
|
|
107
|
+
remoteTrackId != nil, self.player?.currentItem?.trackId == remoteTrackId {
|
|
77
108
|
let time = CMTime(seconds: resumePosition, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
78
109
|
self.player?.seek(to: time)
|
|
79
110
|
}
|
|
@@ -65,6 +65,9 @@ class TrackPlayerCore: NSObject {
|
|
|
65
65
|
|
|
66
66
|
// Gapless playback
|
|
67
67
|
internal var preloadedAssets: [String: AVURLAsset] = [:]
|
|
68
|
+
// playerQueue-owned; bumped on every teardown so in-flight async preloads
|
|
69
|
+
// can't republish a stale asset into the cache after a queue switch
|
|
70
|
+
internal var preloadGeneration: Int = 0
|
|
68
71
|
internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
69
72
|
internal var didRequestUrlsForCurrentItem = false
|
|
70
73
|
|
|
@@ -149,14 +152,26 @@ class TrackPlayerCore: NSObject {
|
|
|
149
152
|
|
|
150
153
|
internal func setupAudioSession() {
|
|
151
154
|
do {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
try
|
|
155
|
+
// Category only — setActive(true) is deferred to first play so merely
|
|
156
|
+
// loading the module doesn't silence other apps' audio.
|
|
157
|
+
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
155
158
|
} catch {
|
|
156
159
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed to setup audio session - \(error)")
|
|
157
160
|
}
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
private var audioSessionActivated = false
|
|
164
|
+
|
|
165
|
+
internal func activateAudioSessionIfNeeded() {
|
|
166
|
+
guard !audioSessionActivated else { return }
|
|
167
|
+
do {
|
|
168
|
+
try AVAudioSession.sharedInstance().setActive(true)
|
|
169
|
+
audioSessionActivated = true
|
|
170
|
+
} catch {
|
|
171
|
+
NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed to activate audio session - \(error)")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
// MARK: - withPlayerQueue (async bridge to player thread)
|
|
161
176
|
|
|
162
177
|
internal func withPlayerQueue<T>(_ block: @escaping () throws -> T) async throws -> T {
|
|
@@ -261,6 +276,7 @@ class TrackPlayerCore: NSObject {
|
|
|
261
276
|
self.pathMonitor = nil
|
|
262
277
|
for asset in self.preloadedAssets.values { asset.cancelLoading() }
|
|
263
278
|
self.preloadedAssets.removeAll()
|
|
279
|
+
self.preloadGeneration += 1
|
|
264
280
|
self.failedItemRetryCounts.removeAll()
|
|
265
281
|
self.redirectResolver.clear()
|
|
266
282
|
}
|
|
@@ -169,12 +169,14 @@ extension TrackPlayerCore {
|
|
|
169
169
|
func playerItemDidPlayToEndTimeInternal(_ notification: Notification) {
|
|
170
170
|
NitroPlayerLogger.log("TrackPlayerCore", "\n🏁 Track finished playing")
|
|
171
171
|
guard let finishedItem = notification.object as? AVPlayerItem else { return }
|
|
172
|
+
// Items without our trackId belong to another AVPlayer in the host app
|
|
173
|
+
guard finishedItem.trackId != nil else { return }
|
|
172
174
|
|
|
173
175
|
// 1. TRACK repeat — handle FIRST, before any temp-track removal
|
|
174
|
-
if currentRepeatMode == .track {
|
|
176
|
+
if currentRepeatMode == .track, finishedItem === player?.currentItem {
|
|
175
177
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
|
|
176
178
|
player?.seek(to: .zero)
|
|
177
|
-
player?.
|
|
179
|
+
player?.rate = Float(currentPlaybackSpeed)
|
|
178
180
|
return // do not remove temp tracks, do not notify track change (same track looping)
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -201,14 +203,18 @@ extension TrackPlayerCore {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
@objc func playerItemFailedToPlayToEndTime(_ notification: Notification) {
|
|
204
|
-
|
|
206
|
+
guard (notification.object as? AVPlayerItem)?.trackId != nil else { return }
|
|
207
|
+
guard let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
|
|
208
|
+
else { return }
|
|
209
|
+
playerQueue.async { [weak self] in
|
|
205
210
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ Playback failed - \(error)")
|
|
206
|
-
notifyPlaybackStateChange(.stopped, .error)
|
|
211
|
+
self?.notifyPlaybackStateChange(.stopped, .error)
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
@objc func playerItemNewErrorLogEntry(_ notification: Notification) {
|
|
211
|
-
guard let item = notification.object as? AVPlayerItem,
|
|
216
|
+
guard let item = notification.object as? AVPlayerItem, item.trackId != nil,
|
|
217
|
+
let errorLog = item.errorLog() else { return }
|
|
212
218
|
for event in errorLog.events ?? [] {
|
|
213
219
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)")
|
|
214
220
|
}
|
|
@@ -220,6 +226,7 @@ extension TrackPlayerCore {
|
|
|
220
226
|
@objc func playerItemTimeJumped(_ notification: Notification) {
|
|
221
227
|
playerQueue.async { [weak self] in
|
|
222
228
|
guard let self, let player = self.player, let currentItem = player.currentItem else { return }
|
|
229
|
+
guard (notification.object as? AVPlayerItem) === currentItem else { return }
|
|
223
230
|
let position = currentItem.currentTime().seconds
|
|
224
231
|
let duration = currentItem.duration.seconds
|
|
225
232
|
NitroPlayerLogger.log("TrackPlayerCore", "🎯 Time jumped (seek detected) - position: \(Int(position))s")
|
|
@@ -301,7 +308,7 @@ extension TrackPlayerCore {
|
|
|
301
308
|
lastItem = item
|
|
302
309
|
}
|
|
303
310
|
currentTrackIndex = 0
|
|
304
|
-
player.
|
|
311
|
+
player.rate = Float(currentPlaybackSpeed)
|
|
305
312
|
|
|
306
313
|
if let firstTrack = currentTracks.first {
|
|
307
314
|
notifyTrackChange(firstTrack, .repeat)
|
|
@@ -454,16 +461,8 @@ extension TrackPlayerCore {
|
|
|
454
461
|
self?.setupBoundaryTimeObserver()
|
|
455
462
|
// First item is buffered and ready — disable stall waiting for gapless inter-track transitions
|
|
456
463
|
self?.player?.automaticallyWaitsToMinimizeStalling = false
|
|
457
|
-
// Update now playing info now that duration is available (
|
|
458
|
-
|
|
459
|
-
let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
|
|
460
|
-
let track = self?.getCurrentTrack()
|
|
461
|
-
DispatchQueue.main.async {
|
|
462
|
-
if let track = track, let state = state {
|
|
463
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
464
|
-
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
465
|
-
}
|
|
466
|
-
}
|
|
464
|
+
// Update now playing info now that duration is available (captured on playerQueue)
|
|
465
|
+
self?.refreshMediaSession()
|
|
467
466
|
} else if item.status == .failed {
|
|
468
467
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
469
468
|
"❌ Item failed — \(item.error?.localizedDescription ?? "unknown error")")
|