react-native-nitro-player 1.7.0-alpha.2 → 1.7.0-alpha.4
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 +9 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +46 -14
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +36 -12
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +16 -6
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +7 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +8 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +26 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +60 -0
- package/ios/cast/CastQueueDiff.swift +58 -0
- package/ios/cast/CastQueueReconciler.swift +228 -0
- package/ios/core/CastSessionManager.swift +215 -31
- package/ios/core/TrackPlayerCast.swift +96 -65
- package/ios/core/TrackPlayerCore.swift +31 -0
- package/ios/core/TrackPlayerListener.swift +19 -3
- package/ios/core/TrackPlayerPlayback.swift +12 -10
- package/ios/core/TrackPlayerQueue.swift +10 -2
- package/ios/core/TrackPlayerQueueBuild.swift +21 -9
- package/ios/core/TrackPlayerTempQueue.swift +27 -15
- package/ios/core/TrackPlayerUrlLoader.swift +6 -2
- package/ios/playlist/PlaylistManager.swift +60 -1
- package/ios/queue/HybridPlayerQueue.swift +40 -13
- package/lib/hooks/callbackManager.d.ts +2 -0
- package/lib/hooks/callbackManager.js +23 -8
- package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
- package/lib/types/PlayerQueue.d.ts +14 -0
- package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
- package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
- package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
- package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
- package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +25 -10
- package/src/specs/TrackPlayer.nitro.ts +11 -0
- package/src/types/PlayerQueue.ts +17 -0
package/README.md
CHANGED
|
@@ -82,6 +82,8 @@ Mutations return **`Promise<void>`** (or **`Promise<string>`** for `createPlayli
|
|
|
82
82
|
| `addTracksToPlaylist(pid, tracks)` | Both | **Async**. Adds multiple tracks to a playlist. |
|
|
83
83
|
| `removeTrackFromPlaylist(pid, tid)` | Both | **Async**. Removes a track from a playlist. |
|
|
84
84
|
| `reorderTrackInPlaylist(pid, tid, idx)` | Both | **Async**. Moves a track to a new position. |
|
|
85
|
+
| `removeTracksFromPlaylist(pid, tids)` | Both | **Async**. Removes several tracks in one call. |
|
|
86
|
+
| `shufflePlaylist(pid, options?)` | Both | **Async**. Shuffles a playlist without stopping playback.|
|
|
85
87
|
|
|
86
88
|
### Platform-Specific APIs
|
|
87
89
|
|
|
@@ -940,6 +942,13 @@ PlayerQueue.removeTrackFromPlaylist(playlistId, trackId)
|
|
|
940
942
|
// Reorder tracks
|
|
941
943
|
PlayerQueue.reorderTrackInPlaylist(playlistId, trackId, newIndex)
|
|
942
944
|
|
|
945
|
+
// Remove several tracks in one native call
|
|
946
|
+
PlayerQueue.removeTracksFromPlaylist(playlistId, [trackId1, trackId2])
|
|
947
|
+
|
|
948
|
+
// Shuffle in place — the playing track keeps playing, no shuffle mode to turn off
|
|
949
|
+
PlayerQueue.shufflePlaylist(playlistId)
|
|
950
|
+
PlayerQueue.shufflePlaylist(playlistId, { keepCurrentTrackFirst: false })
|
|
951
|
+
|
|
943
952
|
// Delete a playlist
|
|
944
953
|
PlayerQueue.deletePlaylist(playlistId)
|
|
945
954
|
```
|
|
@@ -94,16 +94,11 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
|
|
|
94
94
|
core.updatePlaylist(playlistId)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Same semantics as the batch form: unknown ids are skipped, only a missing playlist rejects. */
|
|
97
98
|
override fun removeTrackFromPlaylist(
|
|
98
99
|
playlistId: String,
|
|
99
100
|
trackId: String,
|
|
100
|
-
): Promise<Unit> =
|
|
101
|
-
Promise.async {
|
|
102
|
-
if (!playlistManager.removeTrackFromPlaylist(playlistId, trackId)) {
|
|
103
|
-
throw IllegalArgumentException("Track $trackId not found in playlist $playlistId")
|
|
104
|
-
}
|
|
105
|
-
core.updatePlaylist(playlistId)
|
|
106
|
-
}
|
|
101
|
+
): Promise<Unit> = removeTracksFromPlaylist(playlistId, arrayOf(trackId))
|
|
107
102
|
|
|
108
103
|
override fun reorderTrackInPlaylist(
|
|
109
104
|
playlistId: String,
|
|
@@ -117,6 +112,49 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
|
|
|
117
112
|
core.updatePlaylist(playlistId)
|
|
118
113
|
}
|
|
119
114
|
|
|
115
|
+
/** On the player looper: the live queue must reflect the removal before the promise resolves. */
|
|
116
|
+
override fun removeTracksFromPlaylist(
|
|
117
|
+
playlistId: String,
|
|
118
|
+
trackIds: Array<String>,
|
|
119
|
+
): Promise<Unit> {
|
|
120
|
+
val promise = Promise<Unit>()
|
|
121
|
+
core.enqueue {
|
|
122
|
+
val removed = playlistManager.removeTracksFromPlaylist(playlistId, trackIds.toList())
|
|
123
|
+
if (removed == null) {
|
|
124
|
+
promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
|
|
125
|
+
return@enqueue
|
|
126
|
+
}
|
|
127
|
+
if (removed > 0 && core.getCurrentPlaylistId() == playlistId) core.syncCurrentPlaylistOnQueue()
|
|
128
|
+
promise.resolve(Unit)
|
|
129
|
+
}
|
|
130
|
+
return promise
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** On the player looper: the anchor id is player-thread state. */
|
|
134
|
+
override fun shufflePlaylist(
|
|
135
|
+
playlistId: String,
|
|
136
|
+
options: ShufflePlaylistOptions?,
|
|
137
|
+
): Promise<Unit> {
|
|
138
|
+
val promise = Promise<Unit>()
|
|
139
|
+
core.enqueue {
|
|
140
|
+
val isCurrent = core.getCurrentPlaylistId() == playlistId
|
|
141
|
+
val pin =
|
|
142
|
+
if (isCurrent && options?.keepCurrentTrackFirst != false) {
|
|
143
|
+
core.currentTracks.getOrNull(core.currentTrackIndex)?.id
|
|
144
|
+
} else {
|
|
145
|
+
null
|
|
146
|
+
}
|
|
147
|
+
val changed = playlistManager.shufflePlaylist(playlistId, pin)
|
|
148
|
+
if (changed == null) {
|
|
149
|
+
promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
|
|
150
|
+
return@enqueue
|
|
151
|
+
}
|
|
152
|
+
if (changed && isCurrent) core.syncCurrentPlaylistOnQueue()
|
|
153
|
+
promise.resolve(Unit)
|
|
154
|
+
}
|
|
155
|
+
return promise
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
// ── Playback control ──────────────────────────────────────────────────────
|
|
121
159
|
|
|
122
160
|
/**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
@file:Suppress("ktlint:standard:max-line-length")
|
|
2
|
+
|
|
3
|
+
package com.margelo.nitro.nitroplayer.core
|
|
4
|
+
|
|
5
|
+
import androidx.annotation.OptIn
|
|
6
|
+
import androidx.media3.common.AudioAttributes
|
|
7
|
+
import androidx.media3.common.C
|
|
8
|
+
import androidx.media3.common.util.UnstableApi
|
|
9
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
10
|
+
import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
|
|
11
|
+
import com.margelo.nitro.nitroplayer.media.AudioFocusController
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Audio focus policy. ExoPlayer's built-in handling pauses on every transient loss — even the
|
|
15
|
+
* duckable kind a notification asks for — so 'duck' and 'ignore' take focus into our own hands.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
internal const val DUCK_VOLUME_FACTOR = 0.2f
|
|
19
|
+
|
|
20
|
+
@OptIn(UnstableApi::class)
|
|
21
|
+
internal fun TrackPlayerCore.applyAudioFocusMode(mode: AndroidAudioFocusMode) {
|
|
22
|
+
if (mode == audioFocusMode && (mode == AndroidAudioFocusMode.PAUSE) == (audioFocusController == null)) return
|
|
23
|
+
audioFocusMode = mode
|
|
24
|
+
|
|
25
|
+
audioFocusController?.abandon()
|
|
26
|
+
audioFocusController = null
|
|
27
|
+
isDucked = false
|
|
28
|
+
|
|
29
|
+
val local = exoOrNull() ?: return
|
|
30
|
+
// media3 only knows how to pause; it owns focus for the default mode and nothing else.
|
|
31
|
+
local.setAudioAttributes(mediaAudioAttributes(), mode == AndroidAudioFocusMode.PAUSE)
|
|
32
|
+
local.volume = userVolume
|
|
33
|
+
|
|
34
|
+
if (mode == AndroidAudioFocusMode.DUCK) {
|
|
35
|
+
audioFocusController =
|
|
36
|
+
AudioFocusController(
|
|
37
|
+
context,
|
|
38
|
+
onDuck = { ducked -> applyDuck(ducked) },
|
|
39
|
+
onPause = { playerHandler.post { pauseOnQueue() } },
|
|
40
|
+
)
|
|
41
|
+
if (isExoInitialized && exo.player.isPlaying) audioFocusController?.acquire()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@OptIn(UnstableApi::class)
|
|
46
|
+
internal fun TrackPlayerCore.applyDuck(ducked: Boolean) {
|
|
47
|
+
playerHandler.post {
|
|
48
|
+
isDucked = ducked
|
|
49
|
+
exoOrNull()?.volume = userVolume * if (ducked) DUCK_VOLUME_FACTOR else 1f
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private fun mediaAudioAttributes(): AudioAttributes =
|
|
54
|
+
AudioAttributes
|
|
55
|
+
.Builder()
|
|
56
|
+
.setUsage(C.USAGE_MEDIA)
|
|
57
|
+
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
58
|
+
.build()
|
|
59
|
+
|
|
60
|
+
/** The local ExoPlayer, or null while casting (the CastPlayer has no audio focus of its own). */
|
|
61
|
+
@OptIn(UnstableApi::class)
|
|
62
|
+
internal fun TrackPlayerCore.exoOrNull(): ExoPlayer? {
|
|
63
|
+
if (!isExoInitialized) return null
|
|
64
|
+
return exo.player as? ExoPlayer
|
|
65
|
+
}
|
|
@@ -22,6 +22,8 @@ import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
|
|
|
22
22
|
import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
|
|
23
23
|
import com.margelo.nitro.nitroplayer.media.ExoPlayerBuilder
|
|
24
24
|
import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
|
|
25
|
+
import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
|
|
26
|
+
import com.margelo.nitro.nitroplayer.media.AudioFocusController
|
|
25
27
|
import com.margelo.nitro.nitroplayer.media.MediaSessionManager
|
|
26
28
|
import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
|
|
27
29
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
@@ -71,6 +73,13 @@ class TrackPlayerCore private constructor(
|
|
|
71
73
|
@Volatile internal var currentRepeatMode: RepeatMode = RepeatMode.OFF
|
|
72
74
|
internal var lookaheadCount: Int = 5
|
|
73
75
|
|
|
76
|
+
internal var audioFocusMode: AndroidAudioFocusMode = AndroidAudioFocusMode.PAUSE
|
|
77
|
+
internal var audioFocusController: AudioFocusController? = null
|
|
78
|
+
|
|
79
|
+
/** Volume the app asked for; ducking scales this instead of overwriting it. */
|
|
80
|
+
internal var userVolume: Float = 1f
|
|
81
|
+
internal var isDucked: Boolean = false
|
|
82
|
+
|
|
74
83
|
internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
75
84
|
internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
76
85
|
internal var playerListener: androidx.media3.common.Player.Listener? = null
|
|
@@ -144,21 +153,44 @@ class TrackPlayerCore private constructor(
|
|
|
144
153
|
playerHandler.postDelayed(progressUpdateRunnable, PROGRESS_INTERVAL_MS)
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
internal
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
checkUpcomingTracksForUrls(lookaheadCount)
|
|
156
|
+
internal enum class AnchorOutcome { NONE, KEPT, REMOVED }
|
|
157
|
+
|
|
158
|
+
/** Re-resolves the anchor by id, or moves the cursor to the resume slot when it was removed. */
|
|
159
|
+
internal fun assignCurrentTracks(next: List<TrackItem>): AnchorOutcome {
|
|
160
|
+
val old = currentTracks
|
|
161
|
+
val anchor = old.getOrNull(currentTrackIndex)
|
|
162
|
+
currentTracks = next
|
|
163
|
+
if (anchor == null) return AnchorOutcome.NONE
|
|
164
|
+
val newIndex = next.indexOfFirst { it.id == anchor.id }
|
|
165
|
+
if (newIndex >= 0) {
|
|
166
|
+
currentTrackIndex = newIndex
|
|
167
|
+
return AnchorOutcome.KEPT
|
|
161
168
|
}
|
|
169
|
+
val alive = next.mapTo(HashSet()) { it.id }
|
|
170
|
+
val followerId = old.drop(currentTrackIndex + 1).firstOrNull { it.id in alive }?.id
|
|
171
|
+
val resume = followerId?.let { id -> next.indexOfFirst { it.id == id } } ?: next.size
|
|
172
|
+
currentTrackIndex = resume - 1
|
|
173
|
+
return AnchorOutcome.REMOVED
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Applies the stored playlist to the live queue without re-preparing the playing item. */
|
|
177
|
+
internal fun syncCurrentPlaylistOnQueue() {
|
|
178
|
+
playerHandler.removeCallbacks(updateCurrentPlaylistRunnable)
|
|
179
|
+
val id = currentPlaylistId ?: return
|
|
180
|
+
val playlist = playlistManager.getPlaylist(id) ?: return
|
|
181
|
+
assignCurrentTracks(playlist.tracks)
|
|
182
|
+
if (::exo.isInitialized &&
|
|
183
|
+
exo.currentMediaItem != null &&
|
|
184
|
+
exo.currentMediaItemIndex >= 0
|
|
185
|
+
) {
|
|
186
|
+
rebuildQueueFromCurrentPosition()
|
|
187
|
+
} else {
|
|
188
|
+
updatePlayerQueue(playlist.tracks)
|
|
189
|
+
}
|
|
190
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
internal val updateCurrentPlaylistRunnable = Runnable { syncCurrentPlaylistOnQueue() }
|
|
162
194
|
|
|
163
195
|
// ── Service binding ────────────────────────────────────────────────────
|
|
164
196
|
private var serviceBound = false
|
|
@@ -64,6 +64,8 @@ internal class TrackPlayerEventListener(
|
|
|
64
64
|
val newIdx = currentTracks.indexOfFirst { it.id == trackId }
|
|
65
65
|
if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
|
|
66
66
|
}
|
|
67
|
+
// A transient play-out ends here: restore the user's repeat mode.
|
|
68
|
+
core.applyBackendRepeatMode()
|
|
67
69
|
|
|
68
70
|
val wrappedToStart = currentRepeatMode == RepeatMode.PLAYLIST &&
|
|
69
71
|
currentTrackIndex == 0 && previousTrackIndex == currentTracks.size - 1
|
|
@@ -20,11 +20,17 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
|
|
|
20
20
|
|
|
21
21
|
suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
|
|
22
22
|
|
|
23
|
-
internal fun TrackPlayerCore.playOnQueue()
|
|
23
|
+
internal fun TrackPlayerCore.playOnQueue() {
|
|
24
|
+
audioFocusController?.acquire()
|
|
25
|
+
exo.play()
|
|
26
|
+
}
|
|
24
27
|
|
|
25
28
|
suspend fun TrackPlayerCore.pause() = withPlayerContext { pauseOnQueue() }
|
|
26
29
|
|
|
27
|
-
internal fun TrackPlayerCore.pauseOnQueue()
|
|
30
|
+
internal fun TrackPlayerCore.pauseOnQueue() {
|
|
31
|
+
exo.pause()
|
|
32
|
+
audioFocusController?.abandon()
|
|
33
|
+
}
|
|
28
34
|
|
|
29
35
|
suspend fun TrackPlayerCore.seek(position: Double) = withPlayerContext { seekOnQueue(position) }
|
|
30
36
|
|
|
@@ -55,9 +61,8 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
|
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
|
|
58
|
-
// playFromIndexInternal clears both temp lists (
|
|
59
|
-
|
|
60
|
-
playFromIndexInternal(currentTrackIndex)
|
|
64
|
+
// playFromIndexInternal clears both temp lists; cursor may be -1 (anchor removed).
|
|
65
|
+
if (currentTracks.isEmpty()) exo.seekTo(0) else playFromIndexInternal(maxOf(0, currentTrackIndex))
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
currentTrackIndex > 0 -> {
|
|
@@ -76,12 +81,7 @@ suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext
|
|
|
76
81
|
internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
77
82
|
val previousMode = currentRepeatMode
|
|
78
83
|
currentRepeatMode = mode
|
|
79
|
-
|
|
80
|
-
when (mode) {
|
|
81
|
-
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
|
82
|
-
else -> Player.REPEAT_MODE_OFF
|
|
83
|
-
},
|
|
84
|
-
)
|
|
84
|
+
applyBackendRepeatMode()
|
|
85
85
|
if (!isCastingField && previousMode != mode &&
|
|
86
86
|
(previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
|
|
87
87
|
) {
|
|
@@ -89,18 +89,41 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** True while the playing item was removed from the playlist and is finishing. */
|
|
93
|
+
internal fun TrackPlayerCore.isTransientPlayOut(): Boolean {
|
|
94
|
+
if (!isExoInitialized || currentTemporaryType != TrackPlayerCore.TemporaryType.NONE) return false
|
|
95
|
+
val item = exo.currentMediaItem ?: return false
|
|
96
|
+
return findTrack(item) == null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Follows the user's mode except during a transient play-out, which must finish once. */
|
|
100
|
+
internal fun TrackPlayerCore.backendRepeatMode(): Int =
|
|
101
|
+
when {
|
|
102
|
+
isTransientPlayOut() -> Player.REPEAT_MODE_OFF
|
|
103
|
+
currentRepeatMode == RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
|
104
|
+
else -> Player.REPEAT_MODE_OFF
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
internal fun TrackPlayerCore.applyBackendRepeatMode() {
|
|
108
|
+
if (!isExoInitialized) return
|
|
109
|
+
val desired = backendRepeatMode()
|
|
110
|
+
if (exo.player.repeatMode != desired) exo.setRepeatMode(desired)
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
|
|
93
114
|
|
|
94
115
|
suspend fun TrackPlayerCore.setVolume(volume: Double) = withPlayerContext { setVolumeOnQueue(volume) }
|
|
95
116
|
|
|
96
117
|
internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
|
|
97
118
|
val clamped = volume.coerceIn(0.0, 100.0)
|
|
98
|
-
|
|
119
|
+
userVolume = (clamped / 100.0).toFloat()
|
|
120
|
+
exo.setVolume(userVolume * if (isDucked) DUCK_VOLUME_FACTOR else 1f)
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
|
|
102
124
|
|
|
103
125
|
internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
|
|
126
|
+
config.androidAudioFocus?.let { applyAudioFocusMode(it) }
|
|
104
127
|
config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
|
|
105
128
|
config.lookaheadCount?.let { lookaheadCount = it.toInt() }
|
|
106
129
|
config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
|
|
@@ -185,6 +208,7 @@ internal fun TrackPlayerCore.playSongInternal(
|
|
|
185
208
|
|
|
186
209
|
if (targetPlaylistId == null || songIndex < 0) return
|
|
187
210
|
|
|
211
|
+
playlistManager.setCurrentPlaylistId(targetPlaylistId)
|
|
188
212
|
if (currentPlaylistId != targetPlaylistId) {
|
|
189
213
|
val playlist = playlistManager.getPlaylist(targetPlaylistId) ?: return
|
|
190
214
|
currentPlaylistId = targetPlaylistId
|
|
@@ -72,8 +72,8 @@ suspend fun TrackPlayerCore.getActualQueue(): List<TrackItem> = withPlayerContex
|
|
|
72
72
|
|
|
73
73
|
internal fun TrackPlayerCore.getActualQueueInternal(): List<TrackItem> {
|
|
74
74
|
if (!isExoInitialized) return emptyList()
|
|
75
|
+
// May be -1 (no anchor while a temp plays); the arithmetic below handles it.
|
|
75
76
|
val currentIndex = currentTrackIndex
|
|
76
|
-
if (currentIndex < 0) return emptyList()
|
|
77
77
|
|
|
78
78
|
val queue = ArrayList<TrackItem>(currentTracks.size + playNextStack.size + upNextQueue.size)
|
|
79
79
|
|
|
@@ -88,6 +88,7 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
|
88
88
|
currentTrackIndex = index
|
|
89
89
|
exo.setMediaItems(mediaItems, true)
|
|
90
90
|
exo.prepare()
|
|
91
|
+
applyBackendRepeatMode()
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
// ── Surgical rebuild (preserve current item) ──────────────────────────────
|
|
@@ -105,9 +106,13 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
105
106
|
currentTracks.none { it.id == currentTrackId } &&
|
|
106
107
|
currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
|
|
107
108
|
) {
|
|
108
|
-
if (currentTracks.
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
if (currentTracks.isNotEmpty()) {
|
|
110
|
+
// currentTrackIndex + 1 is the resume slot set by assignCurrentTracks.
|
|
111
|
+
playFromIndexInternal(minOf(currentTrackIndex + 1, currentTracks.size - 1))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
// Transient play-out: the removed item must finish once, not loop.
|
|
115
|
+
applyBackendRepeatMode()
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
// Keep the logical playlist pointer in sync after playlist mutations.
|
|
@@ -158,9 +163,13 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
158
163
|
// ExoPlayer has already prepared, so gapless transitions survive.
|
|
159
164
|
val existingCount = exo.mediaItemCount - currentIndex - 1
|
|
160
165
|
var commonPrefix = 0
|
|
161
|
-
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size) {
|
|
167
|
+
val existing = exo.getMediaItemAt(currentIndex + 1 + commonPrefix)
|
|
168
|
+
val track = newQueueTracks[commonPrefix]
|
|
169
|
+
if (extractTrackId(existing.mediaId) != track.id) break
|
|
170
|
+
// URI must match too — items materialized before lazy URL resolution are unplayable
|
|
171
|
+
val desiredUrl = if (isCastingField) track.url else downloadManager.getEffectiveUrl(track)
|
|
172
|
+
if (existing.localConfiguration?.uri?.toString().orEmpty() != desiredUrl) break
|
|
164
173
|
commonPrefix++
|
|
165
174
|
}
|
|
166
175
|
|
|
@@ -244,6 +253,7 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
|
244
253
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
245
254
|
exo.prepare()
|
|
246
255
|
}
|
|
256
|
+
applyBackendRepeatMode()
|
|
247
257
|
}
|
|
248
258
|
|
|
249
259
|
// ── MediaItem construction (member extension to access downloadManager) ────
|
|
@@ -24,7 +24,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
24
24
|
val safeTracks =
|
|
25
25
|
tracks.filter { track ->
|
|
26
26
|
when {
|
|
27
|
-
|
|
27
|
+
// Same URL is a metadata-only edit (a title or artwork refresh): it reaches
|
|
28
|
+
// the session without touching the media, so gapless is unaffected.
|
|
29
|
+
track.id == currentTrackId && !currentTrackIsEmpty -> track.url == currentTrack?.url
|
|
28
30
|
|
|
29
31
|
// preserve gapless
|
|
30
32
|
track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
|
|
@@ -40,7 +42,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
40
42
|
|
|
41
43
|
// Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
|
|
42
44
|
val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
|
|
43
|
-
|
|
45
|
+
val currentMetadataChanged =
|
|
46
|
+
currentTrackUpdate != null && !currentTrackIsEmpty && currentTrackUpdate.url == currentTrack?.url
|
|
47
|
+
if (!isCastingField && (currentTrackResolvedNow || currentMetadataChanged)) {
|
|
44
48
|
val exoIndex = exo.currentMediaItemIndex
|
|
45
49
|
if (exoIndex >= 0) {
|
|
46
50
|
val playlistId = currentPlaylistId ?: ""
|
|
@@ -53,7 +57,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
53
57
|
if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
|
|
54
58
|
val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
|
|
55
59
|
if (refreshedPlaylist != null) {
|
|
56
|
-
|
|
60
|
+
assignCurrentTracks(refreshedPlaylist.tracks)
|
|
57
61
|
val updatedById = currentTracks.associateBy { it.id }
|
|
58
62
|
playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
|
|
59
63
|
upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package com.margelo.nitro.nitroplayer.media
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioAttributes
|
|
5
|
+
import android.media.AudioFocusRequest
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.os.Build
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owns audio focus for the 'duck' mode. ExoPlayer's own handling pauses on every transient
|
|
11
|
+
* loss, duckable or not, which is why a notification can stop playback outright.
|
|
12
|
+
*/
|
|
13
|
+
class AudioFocusController(
|
|
14
|
+
context: Context,
|
|
15
|
+
private val onDuck: (Boolean) -> Unit,
|
|
16
|
+
private val onPause: () -> Unit,
|
|
17
|
+
) {
|
|
18
|
+
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
19
|
+
private var request: AudioFocusRequest? = null
|
|
20
|
+
private var ducked = false
|
|
21
|
+
private var held = false
|
|
22
|
+
|
|
23
|
+
private val listener =
|
|
24
|
+
AudioManager.OnAudioFocusChangeListener { change ->
|
|
25
|
+
when (change) {
|
|
26
|
+
// Another app took focus for good — stop and let it go.
|
|
27
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
28
|
+
setDucked(false)
|
|
29
|
+
onPause()
|
|
30
|
+
abandon()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
|
|
34
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
|
|
35
|
+
-> setDucked(true)
|
|
36
|
+
|
|
37
|
+
AudioManager.AUDIOFOCUS_GAIN -> setDucked(false)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun setDucked(next: Boolean) {
|
|
42
|
+
if (ducked == next) return
|
|
43
|
+
ducked = next
|
|
44
|
+
onDuck(next)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns false when the system refused focus, so the caller can leave playback alone. */
|
|
48
|
+
fun acquire(): Boolean {
|
|
49
|
+
if (held) return true
|
|
50
|
+
val attributes =
|
|
51
|
+
AudioAttributes
|
|
52
|
+
.Builder()
|
|
53
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
54
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
55
|
+
.build()
|
|
56
|
+
val result =
|
|
57
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
58
|
+
val built =
|
|
59
|
+
AudioFocusRequest
|
|
60
|
+
.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
61
|
+
.setAudioAttributes(attributes)
|
|
62
|
+
.setWillPauseWhenDucked(false)
|
|
63
|
+
.setOnAudioFocusChangeListener(listener)
|
|
64
|
+
.build()
|
|
65
|
+
request = built
|
|
66
|
+
audioManager.requestAudioFocus(built)
|
|
67
|
+
} else {
|
|
68
|
+
@Suppress("DEPRECATION")
|
|
69
|
+
audioManager.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
|
70
|
+
}
|
|
71
|
+
held = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
72
|
+
return held
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun abandon() {
|
|
76
|
+
if (!held) return
|
|
77
|
+
held = false
|
|
78
|
+
setDucked(false)
|
|
79
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
80
|
+
request?.let { audioManager.abandonAudioFocusRequest(it) }
|
|
81
|
+
request = null
|
|
82
|
+
} else {
|
|
83
|
+
@Suppress("DEPRECATION")
|
|
84
|
+
audioManager.abandonAudioFocus(listener)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -8,6 +8,7 @@ import androidx.media3.datasource.DefaultDataSource
|
|
|
8
8
|
import androidx.media3.exoplayer.DefaultLoadControl
|
|
9
9
|
import androidx.media3.exoplayer.ExoPlayer
|
|
10
10
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
11
|
+
import androidx.media3.exoplayer.source.ShuffleOrder
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Builds an [ExoPlayer] with the standard NitroPlayer configuration.
|
|
@@ -59,5 +60,7 @@ object ExoPlayerBuilder {
|
|
|
59
60
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
60
61
|
.setPauseAtEndOfMediaItems(false)
|
|
61
62
|
.build()
|
|
63
|
+
// Queue order is ours; a controller's shuffle toggle must not randomize the window.
|
|
64
|
+
.apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) }
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -79,10 +79,12 @@ class MediaSessionManager(
|
|
|
79
79
|
|
|
80
80
|
private fun updateMediaButtonPreferences() {
|
|
81
81
|
val session = mediaSession ?: return
|
|
82
|
-
// Preferences replace Media3's default layout — prev/next must be declared or they vanish
|
|
82
|
+
// Preferences replace Media3's default layout — prev/next must be declared or they vanish.
|
|
83
|
+
// Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
|
|
84
|
+
// the forward slot they push Next out of the compact notification entirely.
|
|
83
85
|
val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
|
|
84
86
|
session.setMediaButtonPreferences(
|
|
85
|
-
listOf(previousTrackButton(),
|
|
87
|
+
listOf(previousTrackButton(), nextTrackButton(), skipBackButton(), skipForwardButton()) + preserved,
|
|
86
88
|
)
|
|
87
89
|
}
|
|
88
90
|
|
|
@@ -90,6 +92,7 @@ class MediaSessionManager(
|
|
|
90
92
|
CommandButton
|
|
91
93
|
.Builder(CommandButton.ICON_PREVIOUS)
|
|
92
94
|
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
|
95
|
+
.setSlots(CommandButton.SLOT_BACK)
|
|
93
96
|
.setDisplayName(context.getString(R.string.nitro_player_previous_track))
|
|
94
97
|
.build()
|
|
95
98
|
|
|
@@ -97,6 +100,7 @@ class MediaSessionManager(
|
|
|
97
100
|
CommandButton
|
|
98
101
|
.Builder(CommandButton.ICON_NEXT)
|
|
99
102
|
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
|
|
103
|
+
.setSlots(CommandButton.SLOT_FORWARD)
|
|
100
104
|
.setDisplayName(context.getString(R.string.nitro_player_next_track))
|
|
101
105
|
.build()
|
|
102
106
|
|
|
@@ -104,6 +108,7 @@ class MediaSessionManager(
|
|
|
104
108
|
CommandButton
|
|
105
109
|
.Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
|
|
106
110
|
.setPlayerCommand(Player.COMMAND_SEEK_BACK)
|
|
111
|
+
.setSlots(CommandButton.SLOT_OVERFLOW)
|
|
107
112
|
.setDisplayName(
|
|
108
113
|
context.getString(
|
|
109
114
|
R.string.nitro_player_skip_back_seconds,
|
|
@@ -115,6 +120,7 @@ class MediaSessionManager(
|
|
|
115
120
|
CommandButton
|
|
116
121
|
.Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
|
|
117
122
|
.setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
|
|
123
|
+
.setSlots(CommandButton.SLOT_OVERFLOW)
|
|
118
124
|
.setDisplayName(
|
|
119
125
|
context.getString(
|
|
120
126
|
R.string.nitro_player_skip_forward_seconds,
|
|
@@ -7,6 +7,7 @@ import android.os.Handler
|
|
|
7
7
|
import android.os.IBinder
|
|
8
8
|
import android.os.Looper
|
|
9
9
|
import androidx.media3.cast.CastPlayer
|
|
10
|
+
import androidx.media3.common.ForwardingPlayer
|
|
10
11
|
import androidx.media3.common.util.UnstableApi
|
|
11
12
|
import androidx.media3.exoplayer.ExoPlayer
|
|
12
13
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
@@ -15,6 +16,8 @@ import androidx.media3.session.MediaSessionService
|
|
|
15
16
|
import com.google.android.gms.cast.framework.CastContext
|
|
16
17
|
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
17
18
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
19
|
+
import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
|
|
20
|
+
import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
|
|
18
21
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -81,10 +84,32 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
81
84
|
// Build ExoPlayer on main looper (default)
|
|
82
85
|
player = ExoPlayerBuilder.build(this)
|
|
83
86
|
|
|
87
|
+
// The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
|
|
88
|
+
// only ever move inside it — remote Previous seeks to 0 instead of the previous track.
|
|
89
|
+
// Route the remote controls through the same logical skips JS uses.
|
|
90
|
+
val sessionPlayer =
|
|
91
|
+
object : ForwardingPlayer(player) {
|
|
92
|
+
override fun seekToPrevious() {
|
|
93
|
+
trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override fun seekToPreviousMediaItem() {
|
|
97
|
+
trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPreviousMediaItem()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override fun seekToNext() {
|
|
101
|
+
trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNext()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun seekToNextMediaItem() {
|
|
105
|
+
trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
// Build MediaSession
|
|
85
110
|
val playlistManager = PlaylistManager.getInstance(this)
|
|
86
111
|
mediaSession = MediaSession
|
|
87
|
-
.Builder(this,
|
|
112
|
+
.Builder(this, sessionPlayer)
|
|
88
113
|
.setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
|
|
89
114
|
.apply { buildSessionActivity()?.let { setSessionActivity(it) } }
|
|
90
115
|
.build()
|