react-native-nitro-player 1.6.0-alpha.1 → 1.7.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/HybridTrackPlayer.kt +6 -0
- 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 +78 -7
- 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 +22 -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 +8 -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 +33 -11
- 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 +6 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TrackItem.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackItem.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +22 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -0
- package/nitrogen/generated/ios/swift/Func_void_TrackItem.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
- 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 +23 -24
- package/src/hooks/useDownloadActions.ts +35 -11
- 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 +6 -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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.media
|
|
2
2
|
|
|
3
|
+
import android.app.PendingIntent
|
|
3
4
|
import android.content.Intent
|
|
4
5
|
import android.os.Binder
|
|
5
6
|
import android.os.Handler
|
|
@@ -32,6 +33,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
32
33
|
|
|
33
34
|
companion object {
|
|
34
35
|
const val ACTION_LOCAL_BIND = "com.margelo.nitro.nitroplayer.LOCAL_BIND"
|
|
36
|
+
const val EXTRA_STARTED_FROM_NOTIFICATION = "com.margelo.nitro.nitroplayer.STARTED_FROM_NOTIFICATION"
|
|
35
37
|
|
|
36
38
|
@Volatile
|
|
37
39
|
var notificationSmallIconResName: String? = null
|
|
@@ -84,6 +86,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
84
86
|
mediaSession = MediaSession
|
|
85
87
|
.Builder(this, player)
|
|
86
88
|
.setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
|
|
89
|
+
.apply { buildSessionActivity()?.let { setSessionActivity(it) } }
|
|
87
90
|
.build()
|
|
88
91
|
|
|
89
92
|
val provider = DefaultMediaNotificationProvider.Builder(this).build()
|
|
@@ -104,7 +107,13 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
104
107
|
private fun initCast() {
|
|
105
108
|
try {
|
|
106
109
|
val castContext = CastContext.getSharedInstance(this)
|
|
107
|
-
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
|
+
)
|
|
108
117
|
castSessionController =
|
|
109
118
|
CastSessionController(
|
|
110
119
|
castContext = castContext,
|
|
@@ -119,6 +128,18 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
// Content intent for the notification tap — newer Android has no fallback without it
|
|
132
|
+
private fun buildSessionActivity(): PendingIntent? {
|
|
133
|
+
val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
|
|
134
|
+
launch.putExtra(EXTRA_STARTED_FROM_NOTIFICATION, true)
|
|
135
|
+
return PendingIntent.getActivity(
|
|
136
|
+
this,
|
|
137
|
+
0,
|
|
138
|
+
launch,
|
|
139
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
private fun applyNotificationSmallIcon() {
|
|
123
144
|
val name = notificationSmallIconResName ?: return
|
|
124
145
|
val provider = notificationProvider ?: return
|
|
@@ -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)
|
|
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> {
|
|
@@ -188,6 +190,10 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
188
190
|
// No-op on iOS
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
func appStartedWithNotification(callback: @escaping (_ track: TrackItem) -> Void) throws {
|
|
194
|
+
// No-op on iOS — Now Playing controls never launch the app
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
func isAndroidAutoConnected() throws -> Bool { false }
|
|
192
198
|
|
|
193
199
|
// MARK: - Event listeners (v2 — store IDs for cleanup)
|
|
@@ -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
|
}
|