react-native-nitro-player 1.5.0-alpha.0 → 1.5.0-alpha.3

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 CHANGED
@@ -677,7 +677,9 @@ Cast requires native configuration in your **app** (not just the library).
677
677
  <key>NSBonjourServices</key>
678
678
  <array>
679
679
  <string>_googlecast._tcp</string>
680
- <!-- If using a custom receiver, also add _<RECEIVER_APP_ID>._googlecast._tcp -->
680
+ <!-- Receiver-specific service. Use _CC1AD845._googlecast._tcp for the Default
681
+ Media Receiver, or _<YOUR_RECEIVER_APP_ID>._googlecast._tcp for a custom one. -->
682
+ <string>_CC1AD845._googlecast._tcp</string>
681
683
  </array>
682
684
  <!-- Only if you use Cast guest mode (full SDK): -->
683
685
  <key>NSBluetoothAlwaysUsageDescription</key>
@@ -80,6 +80,8 @@ class ExoPlayerCore(
80
80
  }
81
81
  val currentMediaItem: MediaItem? get() = player.currentMediaItem
82
82
  val currentMediaItemIndex: Int get() = player.currentMediaItemIndex
83
+
84
+ fun getMediaItemAt(index: Int): MediaItem = player.getMediaItemAt(index)
83
85
  val currentPosition: Long get() = player.currentPosition
84
86
  val duration: Long get() = player.duration
85
87
  val mediaItemCount: Int get() = player.mediaItemCount
@@ -3,9 +3,9 @@
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import android.app.Activity
6
- import androidx.media3.common.MediaItem
7
6
  import androidx.media3.common.Player
8
7
  import com.margelo.nitro.nitroplayer.CastState
8
+ import com.margelo.nitro.nitroplayer.TrackItem
9
9
  import com.margelo.nitro.nitroplayer.media.NitroCastConfig
10
10
 
11
11
  /**
@@ -21,35 +21,91 @@ import com.margelo.nitro.nitroplayer.media.NitroCastConfig
21
21
  */
22
22
 
23
23
  // ── Backend switching ──────────────────────────────────────────────────────
24
+ // The queue is rebuilt for the target backend (not item-copied): cast needs remote URLs, local prefers download paths.
24
25
 
25
26
  internal fun TrackPlayerCore.switchToCastPlayer() {
26
27
  val controller = castSessionController ?: return
27
- switchToPlayer(controller.castPlayer)
28
- isCastingField = true
28
+ if (!isExoInitialized) return
29
+ val local = exo.player
30
+ if (local === controller.castPlayer) return
31
+
32
+ // Capture context BEFORE swapping — the queue helpers read `exo`, and the cast timeline is still empty.
33
+ val currentId = local.currentMediaItem?.mediaId?.let { extractTrackId(it) }
34
+ val currentTrack = getCurrentTrack() ?: currentTracks.getOrNull(currentTrackIndex)
35
+ val upcoming = buildUpcomingQueueTracks(currentId)
36
+ val positionMs = local.currentPosition.coerceAtLeast(0L)
37
+ val wasPlaying = local.playWhenReady
38
+ val repeat = local.repeatMode
39
+
40
+ playerListener?.let { local.removeListener(it) }
41
+ local.playWhenReady = false // silence local output — audio now comes from the device
42
+
43
+ isCastingField = true // before building items so makeMediaItem picks remote URLs
44
+ exo = ExoPlayerCore(controller.castPlayer)
45
+ playerListener?.let { controller.castPlayer.addListener(it) }
46
+ setMediaSessionPlayer(controller.castPlayer)
47
+
48
+ val castList = ArrayList<TrackItem>()
49
+ currentTrack?.let { castList.add(it) }
50
+ castList.addAll(upcoming)
51
+ val playable = castableUpcoming(castList)
52
+
53
+ if (playable.isNotEmpty()) {
54
+ lastCastWaitTrackId = null
55
+ val items = playable.map { makeMediaItem(it, mediaIdFor(it)) }
56
+ // Resume mid-track only when the receiver starts on the locally playing track.
57
+ val resumeFromCurrent = currentTrack != null && playable.first().id == currentTrack.id
58
+ controller.castPlayer.setMediaItems(items, 0, if (resumeFromCurrent) positionMs else 0L)
59
+ controller.castPlayer.repeatMode = repeat
60
+ controller.castPlayer.playWhenReady = wasPlaying
61
+ controller.castPlayer.prepare()
62
+ } else {
63
+ // Nothing castable yet (lazy queue) — request URLs; updateTracks will load.
64
+ checkUpcomingTracksForUrls(lookaheadCount)
65
+ }
66
+
29
67
  notifyCastStateChange(CastState.CONNECTED, controller.currentDeviceName())
30
68
  }
31
69
 
32
70
  internal fun TrackPlayerCore.switchToLocalPlayer() {
33
71
  val controller = castSessionController ?: return
34
- switchToPlayer(controller.localPlayer)
35
- isCastingField = false
36
- notifyCastStateChange(controller.getCastState(), controller.currentDeviceName())
37
- }
38
-
39
- private fun TrackPlayerCore.switchToPlayer(target: Player) {
40
72
  if (!isExoInitialized) return
41
- val current = exo.player
42
- if (current === target) return
43
-
44
- // Move listener, queue and position from the current player to the target,
45
- // then make the target authoritative for the MediaSession & notification.
46
- playerListener?.let { current.removeListener(it) }
47
- transferPlaybackState(current, target)
48
- current.playWhenReady = false // silence the player we're leaving
73
+ val cast = exo.player
74
+ if (cast === controller.localPlayer) return
75
+
76
+ // Capture context before swapping (the cast timeline empties once the session ends).
77
+ val currentId = cast.currentMediaItem?.mediaId?.let { extractTrackId(it) }
78
+ val currentTrack = getCurrentTrack() ?: currentTracks.getOrNull(currentTrackIndex)
79
+ val upcoming = buildUpcomingQueueTracks(currentId)
80
+ val positionMs = cast.currentPosition.coerceAtLeast(0L)
81
+ val wasPlaying = cast.playWhenReady
82
+ val repeat = cast.repeatMode
83
+
84
+ playerListener?.let { cast.removeListener(it) }
85
+ cast.playWhenReady = false
86
+
87
+ isCastingField = false // before building items so makeMediaItem restores download paths
88
+ lastCastWaitTrackId = null
89
+ exo = ExoPlayerCore(controller.localPlayer)
90
+ playerListener?.let { controller.localPlayer.addListener(it) }
91
+ setMediaSessionPlayer(controller.localPlayer)
92
+
93
+ val localList = ArrayList<TrackItem>()
94
+ currentTrack?.let { localList.add(it) }
95
+ localList.addAll(upcoming)
96
+
97
+ if (localList.isNotEmpty()) {
98
+ val items = localList.map { makeMediaItem(it, mediaIdFor(it)) }
99
+ controller.localPlayer.setMediaItems(items, 0, positionMs)
100
+ controller.localPlayer.repeatMode = repeat
101
+ controller.localPlayer.playWhenReady = wasPlaying
102
+ controller.localPlayer.prepare()
103
+ }
49
104
 
50
- exo = ExoPlayerCore(target)
51
- playerListener?.let { target.addListener(it) }
105
+ notifyCastStateChange(controller.getCastState(), controller.currentDeviceName())
106
+ }
52
107
 
108
+ private fun TrackPlayerCore.setMediaSessionPlayer(target: Player) {
53
109
  try {
54
110
  castSessionController?.let { it.mediaSession.player = target }
55
111
  } catch (e: Exception) {
@@ -57,24 +113,6 @@ private fun TrackPlayerCore.switchToPlayer(target: Player) {
57
113
  }
58
114
  }
59
115
 
60
- /** Copy the queue, index, position, repeat mode and play-when-ready from one player to another. */
61
- private fun transferPlaybackState(
62
- from: Player,
63
- to: Player,
64
- ) {
65
- val count = from.mediaItemCount
66
- if (count == 0) return
67
- val items = ArrayList<MediaItem>(count)
68
- for (i in 0 until count) items.add(from.getMediaItemAt(i))
69
- val index = from.currentMediaItemIndex.coerceAtLeast(0)
70
- val position = if (from.currentPosition >= 0) from.currentPosition else 0L
71
-
72
- to.setMediaItems(items, index, position)
73
- to.repeatMode = from.repeatMode
74
- to.playWhenReady = from.playWhenReady
75
- to.prepare()
76
- }
77
-
78
116
  // ── Notifications ──────────────────────────────────────────────────────────
79
117
 
80
118
  internal fun TrackPlayerCore.notifyCastStateChange(
@@ -100,6 +100,9 @@ class TrackPlayerCore private constructor(
100
100
  /** True while the active backend is the CastPlayer (audio routed to a remote device). */
101
101
  @Volatile internal var isCastingField: Boolean = false
102
102
 
103
+ /** Last track announced from a cast "waiting for URL" state — dedups onChangeTrack across updateTracks re-entries. */
104
+ internal var lastCastWaitTrackId: String? = null
105
+
103
106
  // ── Progress & playlist-update runnables ───────────────────────────────
104
107
  internal val progressUpdateRunnable =
105
108
  object : Runnable {
@@ -6,6 +6,7 @@ import android.net.Uri
6
6
  import androidx.media3.common.MediaItem
7
7
  import androidx.media3.common.MediaMetadata
8
8
  import androidx.media3.common.Player
9
+ import com.margelo.nitro.nitroplayer.Reason
9
10
  import com.margelo.nitro.nitroplayer.TrackItem
10
11
 
11
12
  /**
@@ -15,17 +16,63 @@ import com.margelo.nitro.nitroplayer.TrackItem
15
16
  * when jumping to a specific index.
16
17
  */
17
18
 
19
+ // ── Cast-safety helpers ────────────────────────────────────────────────────
20
+
21
+ /** A track the Cast receiver can actually load: non-empty remote (non-local) URL. */
22
+ internal fun TrackPlayerCore.isTrackCastable(track: TrackItem): Boolean =
23
+ track.url.isNotEmpty() &&
24
+ !track.url.startsWith("/") &&
25
+ !track.url.startsWith("file:")
26
+
27
+ /** Receiver-safe run: keeps castable tracks, skips local-only ones, stops at the first lazy (empty-URL) track whose resolution is pending — receivers auto-skip failed loads instead of waiting like ExoPlayer. */
28
+ internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<TrackItem> {
29
+ val result = ArrayList<TrackItem>(tracks.size)
30
+ for (track in tracks) {
31
+ when {
32
+ isTrackCastable(track) -> result.add(track)
33
+ track.url.isEmpty() -> break
34
+ // local-only → skip and keep scanning
35
+ }
36
+ }
37
+ return result
38
+ }
39
+
40
+ internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
41
+ val playlistId = currentPlaylistId ?: ""
42
+ return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
43
+ }
44
+
18
45
  // ── Full rebuild (jump to index) ───────────────────────────────────────────
19
46
 
20
47
  internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
21
48
  if (!isExoInitialized) return
22
49
  if (index < 0 || index >= currentTracks.size) return
23
50
 
24
- val playlistId = currentPlaylistId ?: ""
51
+ if (isCastingField) {
52
+ currentTrackIndex = index
53
+ val target = currentTracks[index]
54
+ val castTracks = castableUpcoming(currentTracks.subList(index, currentTracks.size))
55
+ if (castTracks.isEmpty()) {
56
+ // Lazy target — silence the receiver and wait for updateTracks to reload (dedup so re-entries don't spam JS).
57
+ if (exo.mediaItemCount > 0) exo.clearMediaItems()
58
+ if (lastCastWaitTrackId != target.id) {
59
+ lastCastWaitTrackId = target.id
60
+ notifyTrackChange(target, Reason.SKIP)
61
+ }
62
+ checkUpcomingTracksForUrls(lookaheadCount)
63
+ return
64
+ }
65
+ lastCastWaitTrackId = null
66
+ // Single atomic queueLoad — no separate clear (each queue op is a receiver RPC).
67
+ exo.setMediaItems(castTracks.map { makeMediaItem(it, mediaIdFor(it)) }, true)
68
+ exo.prepare()
69
+ checkUpcomingTracksForUrls(lookaheadCount)
70
+ return
71
+ }
72
+
25
73
  val mediaItems =
26
74
  currentTracks.subList(index, currentTracks.size).map { track ->
27
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
28
- makeMediaItem(track, mediaId)
75
+ makeMediaItem(track, mediaIdFor(track))
29
76
  }
30
77
 
31
78
  currentTrackIndex = index
@@ -63,8 +110,38 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
63
110
  }
64
111
  }
65
112
 
66
- val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
67
113
  val currentId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
114
+ var newQueueTracks: List<TrackItem> = buildUpcomingQueueTracks(currentId)
115
+
116
+ if (isCastingField) {
117
+ newQueueTracks = castableUpcoming(newQueueTracks)
118
+
119
+ // Every queue edit is a receiver RPC — skip the rebuild when the remote queue already matches.
120
+ val itemCount = exo.mediaItemCount
121
+ if (itemCount - currentIndex - 1 == newQueueTracks.size) {
122
+ var inSync = true
123
+ for (i in newQueueTracks.indices) {
124
+ val existingId = extractTrackId(exo.getMediaItemAt(currentIndex + 1 + i).mediaId)
125
+ if (existingId != newQueueTracks[i].id) {
126
+ inSync = false
127
+ break
128
+ }
129
+ }
130
+ if (inSync) return
131
+ }
132
+ }
133
+
134
+ val newMediaItems = newQueueTracks.map { makeMediaItem(it, mediaIdFor(it)) }
135
+
136
+ if (exo.mediaItemCount > currentIndex + 1) {
137
+ exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
138
+ }
139
+ exo.addMediaItems(newMediaItems)
140
+ }
141
+
142
+ /** Desired upcoming list after the current track: playNext + upNext + remaining originals, with the playing temp track ([currentId]) skipped. */
143
+ internal fun TrackPlayerCore.buildUpcomingQueueTracks(currentId: String?): List<TrackItem> {
144
+ val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
68
145
 
69
146
  // playNext stack — skip the currently playing track by ID (not position)
70
147
  if (currentTemporaryType == TrackPlayerCore.TemporaryType.PLAY_NEXT && currentId != null) {
@@ -94,34 +171,39 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
94
171
  newQueueTracks.addAll(upNextQueue)
95
172
  }
96
173
 
97
- // Remaining original tracks (after currentTrackIndex, not after ExoPlayer's currentIndex)
174
+ // Remaining original tracks (after currentTrackIndex, not after the player's currentIndex)
98
175
  if (currentTrackIndex + 1 < currentTracks.size) {
99
176
  newQueueTracks.addAll(currentTracks.subList(currentTrackIndex + 1, currentTracks.size))
100
177
  }
101
-
102
- val playlistId = currentPlaylistId ?: ""
103
- val newMediaItems =
104
- newQueueTracks.map { track ->
105
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
106
- makeMediaItem(track, mediaId)
107
- }
108
-
109
- if (exo.mediaItemCount > currentIndex + 1) {
110
- exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
111
- }
112
- exo.addMediaItems(newMediaItems)
178
+ return newQueueTracks
113
179
  }
114
180
 
115
181
  // ── Full queue set (initial load or no active item) ───────────────────────
116
182
 
117
183
  internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
118
184
  currentTracks = tracks
119
- val playlistId = currentPlaylistId ?: ""
120
- val mediaItems =
121
- tracks.map { track ->
122
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
123
- makeMediaItem(track, mediaId)
185
+
186
+ if (isCastingField) {
187
+ currentTrackIndex = 0
188
+ val castTracks = castableUpcoming(tracks)
189
+ if (castTracks.isEmpty()) {
190
+ // Lazy first track — silence the receiver and wait for updateTracks to supply URLs.
191
+ if (exo.mediaItemCount > 0) exo.clearMediaItems()
192
+ val first = tracks.firstOrNull()
193
+ if (first != null && lastCastWaitTrackId != first.id) {
194
+ lastCastWaitTrackId = first.id
195
+ notifyTrackChange(first, null)
196
+ }
197
+ checkUpcomingTracksForUrls(lookaheadCount)
198
+ return
124
199
  }
200
+ lastCastWaitTrackId = null
201
+ exo.setMediaItems(castTracks.map { makeMediaItem(it, mediaIdFor(it)) }, true)
202
+ exo.prepare()
203
+ return
204
+ }
205
+
206
+ val mediaItems = tracks.map { makeMediaItem(it, mediaIdFor(it)) }
125
207
  exo.setMediaItems(mediaItems, true)
126
208
  if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
127
209
  exo.prepare()
@@ -148,7 +230,8 @@ internal fun TrackPlayerCore.makeMediaItem(
148
230
  }
149
231
  }
150
232
 
151
- val effectiveUrl = downloadManager.getEffectiveUrl(track)
233
+ // The receiver fetches media itself, so local download paths are unplayable there — cast uses the remote URL.
234
+ val effectiveUrl = if (isCastingField) track.url else downloadManager.getEffectiveUrl(track)
152
235
 
153
236
  // Register custom HTTP headers (e.g. Authorization) from extraPayload.headers so they are
154
237
  // injected into the request for this URL. Applies to remote streams only — see ExoPlayerBuilder.
@@ -36,12 +36,13 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
36
36
 
37
37
  val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
38
38
 
39
- // Replace current track's MediaItem if it was empty-URL and now has a URL
40
- if (currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()) {
39
+ // Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
40
+ val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
41
+ if (!isCastingField && currentTrackResolvedNow) {
41
42
  val exoIndex = exo.currentMediaItemIndex
42
43
  if (exoIndex >= 0) {
43
44
  val playlistId = currentPlaylistId ?: ""
44
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate.id}" else currentTrackUpdate.id
45
+ val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate!!.id}" else currentTrackUpdate!!.id
45
46
  exo.replaceMediaItem(exoIndex, makeMediaItem(currentTrackUpdate, mediaId))
46
47
  if (exo.playbackState == Player.STATE_IDLE) exo.prepare()
47
48
  }
@@ -55,6 +56,17 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
55
56
  playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
56
57
  upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
57
58
  }
59
+
60
+ if (isCastingField) {
61
+ // Resolved current track (or empty receiver) → atomic reload from the current index; otherwise resync only the upcoming items.
62
+ if (currentTrackResolvedNow || exo.mediaItemCount == 0) {
63
+ rebuildQueueAndPlayFromIndex(currentTrackIndex)
64
+ } else {
65
+ rebuildQueueFromCurrentPosition()
66
+ }
67
+ return@withPlayerContext
68
+ }
69
+
58
70
  rebuildQueueFromCurrentPosition()
59
71
  }
60
72
  }