react-native-nitro-player 1.5.0 → 1.6.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.
Files changed (63) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  13. package/ios/HybridTrackPlayer.swift +58 -29
  14. package/ios/core/TrackPlayerCore.swift +22 -1
  15. package/ios/core/TrackPlayerListener.swift +84 -5
  16. package/ios/core/TrackPlayerNotify.swift +13 -7
  17. package/ios/core/TrackPlayerPlayback.swift +75 -39
  18. package/ios/core/TrackPlayerQueue.swift +28 -4
  19. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  20. package/ios/core/TrackPlayerRecovery.swift +1 -1
  21. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  22. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  23. package/ios/download/DownloadDatabase.swift +41 -13
  24. package/ios/media/MediaSessionManager.swift +19 -12
  25. package/ios/queue/HybridPlayerQueue.swift +10 -5
  26. package/lib/hooks/callbackManager.d.ts +27 -1
  27. package/lib/hooks/callbackManager.js +100 -1
  28. package/lib/hooks/index.d.ts +1 -0
  29. package/lib/hooks/index.js +1 -0
  30. package/lib/hooks/useActualQueue.js +18 -12
  31. package/lib/hooks/useNowPlaying.js +10 -2
  32. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  33. package/lib/hooks/useTimedMetadata.js +21 -0
  34. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  35. package/lib/types/PlayerQueue.d.ts +7 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  37. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  38. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  39. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  40. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
  44. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  45. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  47. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  48. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  49. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  50. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  51. package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
  52. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  53. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  54. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
  55. package/package.json +1 -1
  56. package/src/components/CastButton.tsx +14 -2
  57. package/src/hooks/callbackManager.ts +134 -2
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useActualQueue.ts +18 -13
  60. package/src/hooks/useNowPlaying.ts +12 -2
  61. package/src/hooks/useTimedMetadata.ts +25 -0
  62. package/src/specs/TrackPlayer.nitro.ts +6 -0
  63. package/src/types/PlayerQueue.ts +8 -0
@@ -28,6 +28,39 @@ final class DownloadDatabase {
28
28
  private let queue = DispatchQueue(
29
29
  label: "com.nitroplayer.downloadDatabase", attributes: .concurrent)
30
30
 
31
+ /// Memoized `fileExists` verdicts, keyed by track ID.
32
+ ///
33
+ /// Queue building calls `isTrackDownloaded` / `getDownloadedTrack` once per track,
34
+ /// and each call used to stat() the file — O(playlist) syscalls per rebuild, on the
35
+ /// thread that also drives playback. Downloads only change through this class (or
36
+ /// through the user deleting files behind our back, which `syncDownloads()` handles),
37
+ /// so the verdict is cached and invalidated on every mutation.
38
+ /// Guarded by its own lock rather than `queue` so it can be filled from inside
39
+ /// concurrent read blocks (`queue.sync`) without a barrier.
40
+ private var existenceCache: [String: Bool] = [:]
41
+ private let existenceCacheLock = NSLock()
42
+
43
+ private func cachedFileExists(trackId: String, path: @autoclosure () -> String) -> Bool {
44
+ existenceCacheLock.lock()
45
+ let cached = existenceCache[trackId]
46
+ existenceCacheLock.unlock()
47
+ if let cached { return cached }
48
+
49
+ let exists = FileManager.default.fileExists(atPath: path())
50
+ existenceCacheLock.lock()
51
+ existenceCache[trackId] = exists
52
+ existenceCacheLock.unlock()
53
+ return exists
54
+ }
55
+
56
+ /// Drops every memoized verdict. Called on any record mutation, and safe to call
57
+ /// externally when files may have changed outside the app.
58
+ func invalidateExistenceCache() {
59
+ existenceCacheLock.lock()
60
+ existenceCache.removeAll()
61
+ existenceCacheLock.unlock()
62
+ }
63
+
31
64
  // MARK: - Initialization
32
65
 
33
66
  private init() {
@@ -70,22 +103,14 @@ final class DownloadDatabase {
70
103
  NitroPlayerLogger.log("DownloadDatabase", "🔍 Track \(trackId) NOT found in database")
71
104
  return false
72
105
  }
73
- // Verify file still exists
74
- let absolutePath = resolveAbsolutePath(for: record)
75
- let exists = FileManager.default.fileExists(atPath: absolutePath)
76
- if exists {
77
- NitroPlayerLogger.log("DownloadDatabase", "✅ Track \(trackId) IS downloaded at \(absolutePath)")
78
- } else {
79
- NitroPlayerLogger.log("DownloadDatabase", "❌ Track \(trackId) record exists but file NOT found at \(absolutePath)")
80
- }
81
- return exists
106
+ // Verify file still exists (memoized — this runs once per track per queue rebuild)
107
+ return cachedFileExists(trackId: trackId, path: resolveAbsolutePath(for: record))
82
108
  }
83
109
  }
84
110
 
85
111
  private func _isTrackDownloadedUnsafe(trackId: String) -> Bool {
86
112
  guard let record = downloadedTracks[trackId] else { return false }
87
- let absolutePath = resolveAbsolutePath(for: record)
88
- return FileManager.default.fileExists(atPath: absolutePath)
113
+ return cachedFileExists(trackId: trackId, path: resolveAbsolutePath(for: record))
89
114
  }
90
115
 
91
116
  func isPlaylistDownloaded(playlistId: String) -> Bool {
@@ -137,8 +162,8 @@ final class DownloadDatabase {
137
162
  let absolutePath = resolveAbsolutePath(for: record)
138
163
  NitroPlayerLogger.log("DownloadDatabase", " Found record, checking file at: \(absolutePath)")
139
164
 
140
- // Verify file still exists
141
- guard FileManager.default.fileExists(atPath: absolutePath) else {
165
+ // Verify file still exists (memoized)
166
+ guard cachedFileExists(trackId: trackId, path: absolutePath) else {
142
167
  NitroPlayerLogger.log("DownloadDatabase", " ❌ File does NOT exist, cleaning up record")
143
168
  // File was deleted externally, clean up record
144
169
  queue.async(flags: .barrier) {
@@ -357,6 +382,9 @@ final class DownloadDatabase {
357
382
  // MARK: - Persistence
358
383
 
359
384
  private func saveToDisk() {
385
+ // Every record mutation funnels through here, so this is the one place that has
386
+ // to drop memoized fileExists verdicts.
387
+ invalidateExistenceCache()
360
388
  do {
361
389
  let tracksData = try JSONEncoder().encode(downloadedTracks)
362
390
  // Convert Set to Array for encoding
@@ -31,7 +31,11 @@ class MediaSessionManager {
31
31
  // Cached values received from playerQueue — main-thread-only reads (no sync needed)
32
32
  private var cachedTrack: TrackItem?
33
33
  private var cachedState: PlayerState?
34
- private var cachedQueue: [TrackItem] = []
34
+ // Only the queue length and the current track's position are needed — never the
35
+ // items themselves. Copying the whole queue on every state change was O(playlist)
36
+ // per event; these two Ints are computed on playerQueue instead.
37
+ private var cachedQueueCount: Int = 0
38
+ private var cachedPositionInQueue: Int = -1
35
39
 
36
40
  init() {
37
41
  setupRemoteCommandCenter()
@@ -56,10 +60,13 @@ class MediaSessionManager {
56
60
  //
57
61
  // Receives pre-computed values captured on playerQueue — no player access here.
58
62
 
59
- func updateFromPlayerQueue(track: TrackItem, state: PlayerState, queue: [TrackItem]) {
63
+ func updateFromPlayerQueue(
64
+ track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
65
+ ) {
60
66
  cachedTrack = track
61
67
  cachedState = state
62
- cachedQueue = queue
68
+ cachedQueueCount = queueCount
69
+ cachedPositionInQueue = positionInQueue
63
70
  refreshInternal()
64
71
  }
65
72
 
@@ -88,11 +95,11 @@ class MediaSessionManager {
88
95
  return
89
96
  }
90
97
 
91
- let queue = cachedQueue
92
- let positionInQueue = queue.firstIndex(where: { $0.id == track.id }) ?? -1
93
-
94
- updateNowPlayingInfoInternal(track: track, state: state, queue: queue, positionInQueue: positionInQueue)
95
- updateCommandCenterState(state: state, queue: queue, positionInQueue: positionInQueue)
98
+ updateNowPlayingInfoInternal(
99
+ track: track, state: state,
100
+ queueCount: cachedQueueCount, positionInQueue: cachedPositionInQueue)
101
+ updateCommandCenterState(
102
+ state: state, queueCount: cachedQueueCount, positionInQueue: cachedPositionInQueue)
96
103
  }
97
104
 
98
105
  // MARK: - Now Playing Info
@@ -100,7 +107,7 @@ class MediaSessionManager {
100
107
  private func updateNowPlayingInfoInternal(
101
108
  track: TrackItem,
102
109
  state: PlayerState,
103
- queue: [TrackItem],
110
+ queueCount: Int,
104
111
  positionInQueue: Int
105
112
  ) {
106
113
  let playerDuration = state.totalDuration
@@ -125,7 +132,7 @@ class MediaSessionManager {
125
132
  MPMediaItemPropertyPlaybackDuration: effectiveDuration,
126
133
  MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
127
134
  MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
128
- MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queue.count),
135
+ MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queueCount),
129
136
  MPNowPlayingInfoPropertyPlaybackQueueIndex: max(0, positionInQueue),
130
137
  ]
131
138
 
@@ -238,12 +245,12 @@ class MediaSessionManager {
238
245
 
239
246
  private func updateCommandCenterState(
240
247
  state: PlayerState,
241
- queue: [TrackItem],
248
+ queueCount: Int,
242
249
  positionInQueue: Int
243
250
  ) {
244
251
  let commandCenter = MPRemoteCommandCenter.shared()
245
252
  let hasCurrentTrack = positionInQueue >= 0
246
- let isNotLast = positionInQueue < queue.count - 1
253
+ let isNotLast = positionInQueue < queueCount - 1
247
254
 
248
255
  let playerDuration = state.totalDuration
249
256
  let hasDuration = playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite
@@ -77,14 +77,19 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
77
77
 
78
78
  // MARK: - Playback control
79
79
 
80
+ /// Enqueued synchronously on the player queue so a `loadPlaylist()` followed by
81
+ /// `play()` from JS cannot be reordered — see HybridTrackPlayer.enqueue.
80
82
  func loadPlaylist(playlistId: String, index: Double?) throws -> Promise<Void> {
81
- Promise.async {
82
- let startIndex = index.map { Int($0) }
83
+ let startIndex = index.map { Int($0) }
84
+ let promise = Promise<Void>()
85
+ core.playerQueue.async {
83
86
  // Update PlaylistManager.currentPlaylistId so getCurrentPlaylistId() returns correctly
84
- let loaded = self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex)
85
- guard loaded else { return }
86
- await self.core.loadPlaylist(playlistId: playlistId, startIndex: startIndex)
87
+ if self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex) {
88
+ self.core.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex)
89
+ }
90
+ promise.resolve(withResult: ())
87
91
  }
92
+ return promise
88
93
  }
89
94
 
90
95
  func getCurrentPlaylistId() throws -> Variant_NullType_String {
@@ -1,8 +1,10 @@
1
- import type { TrackItem, TrackPlayerState, Reason } from '../types/PlayerQueue';
1
+ import type { TrackItem, TrackPlayerState, Reason, TimedMetadata } from '../types/PlayerQueue';
2
2
  type PlaybackStateCallback = (state: TrackPlayerState, reason?: Reason) => void;
3
3
  type TrackChangeCallback = (track: TrackItem, reason?: Reason) => void;
4
4
  type PlaybackProgressCallback = (position: number, totalDuration: number, isManuallySeeked?: boolean) => void;
5
5
  type SeekCallback = (position: number, totalDuration: number) => void;
6
+ type TimedMetadataCallback = (metadata: TimedMetadata) => void;
7
+ type TemporaryQueueCallback = (playNextQueue: TrackItem[], upNextQueue: TrackItem[]) => void;
6
8
  /**
7
9
  * Internal subscription manager that allows multiple hooks to subscribe
8
10
  * to a single native callback. This solves the problem where registering
@@ -13,10 +15,16 @@ declare class CallbackSubscriptionManager {
13
15
  private trackChangeSubscribers;
14
16
  private playbackProgressSubscribers;
15
17
  private seekSubscribers;
18
+ private timedMetadataSubscribers;
19
+ private temporaryQueueSubscribers;
20
+ private playlistsChangedSubscribers;
21
+ private isPlaylistsChangedRegistered;
16
22
  private isPlaybackStateRegistered;
17
23
  private isTrackChangeRegistered;
18
24
  private isPlaybackProgressRegistered;
19
25
  private isSeekRegistered;
26
+ private isTimedMetadataRegistered;
27
+ private isTemporaryQueueRegistered;
20
28
  /**
21
29
  * Subscribe to playback state changes
22
30
  * @returns Unsubscribe function
@@ -40,7 +48,25 @@ declare class CallbackSubscriptionManager {
40
48
  */
41
49
  subscribeToSeek(callback: SeekCallback): () => void;
42
50
  private ensurePlaybackProgressRegistered;
51
+ /**
52
+ * Subscribe to playNext / upNext changes
53
+ * @returns Unsubscribe function
54
+ */
55
+ subscribeToTemporaryQueueChange(callback: TemporaryQueueCallback): () => void;
56
+ /**
57
+ * Subscribe to playlist mutations (tracks added/removed/reordered).
58
+ * @returns Unsubscribe function
59
+ */
60
+ subscribeToPlaylistsChanged(callback: () => void): () => void;
61
+ private ensurePlaylistsChangedRegistered;
62
+ private ensureTemporaryQueueRegistered;
43
63
  private ensureSeekRegistered;
64
+ /**
65
+ * Subscribe to in-stream (timed) metadata events
66
+ * @returns Unsubscribe function
67
+ */
68
+ subscribeToTimedMetadata(callback: TimedMetadataCallback): () => void;
69
+ private ensureTimedMetadataRegistered;
44
70
  }
45
71
  export declare const callbackManager: CallbackSubscriptionManager;
46
72
  export {};
@@ -1,4 +1,4 @@
1
- import { TrackPlayer } from '../index';
1
+ import { PlayerQueue, TrackPlayer } from '../index';
2
2
  /**
3
3
  * Internal subscription manager that allows multiple hooks to subscribe
4
4
  * to a single native callback. This solves the problem where registering
@@ -9,10 +9,16 @@ class CallbackSubscriptionManager {
9
9
  trackChangeSubscribers = new Set();
10
10
  playbackProgressSubscribers = new Set();
11
11
  seekSubscribers = new Set();
12
+ timedMetadataSubscribers = new Set();
13
+ temporaryQueueSubscribers = new Set();
14
+ playlistsChangedSubscribers = new Set();
15
+ isPlaylistsChangedRegistered = false;
12
16
  isPlaybackStateRegistered = false;
13
17
  isTrackChangeRegistered = false;
14
18
  isPlaybackProgressRegistered = false;
15
19
  isSeekRegistered = false;
20
+ isTimedMetadataRegistered = false;
21
+ isTemporaryQueueRegistered = false;
16
22
  /**
17
23
  * Subscribe to playback state changes
18
24
  * @returns Unsubscribe function
@@ -117,6 +123,68 @@ class CallbackSubscriptionManager {
117
123
  console.error('[CallbackManager] Failed to register playback progress callback:', error);
118
124
  }
119
125
  }
126
+ /**
127
+ * Subscribe to playNext / upNext changes
128
+ * @returns Unsubscribe function
129
+ */
130
+ subscribeToTemporaryQueueChange(callback) {
131
+ this.temporaryQueueSubscribers.add(callback);
132
+ this.ensureTemporaryQueueRegistered();
133
+ return () => {
134
+ this.temporaryQueueSubscribers.delete(callback);
135
+ };
136
+ }
137
+ /**
138
+ * Subscribe to playlist mutations (tracks added/removed/reordered).
139
+ * @returns Unsubscribe function
140
+ */
141
+ subscribeToPlaylistsChanged(callback) {
142
+ this.playlistsChangedSubscribers.add(callback);
143
+ this.ensurePlaylistsChangedRegistered();
144
+ return () => {
145
+ this.playlistsChangedSubscribers.delete(callback);
146
+ };
147
+ }
148
+ ensurePlaylistsChangedRegistered() {
149
+ if (this.isPlaylistsChangedRegistered)
150
+ return;
151
+ try {
152
+ PlayerQueue.onPlaylistsChanged(() => {
153
+ this.playlistsChangedSubscribers.forEach((subscriber) => {
154
+ try {
155
+ subscriber();
156
+ }
157
+ catch (error) {
158
+ console.error('[CallbackManager] Error in playlists changed subscriber:', error);
159
+ }
160
+ });
161
+ });
162
+ this.isPlaylistsChangedRegistered = true;
163
+ }
164
+ catch (error) {
165
+ console.error('[CallbackManager] Failed to register playlists changed callback:', error);
166
+ }
167
+ }
168
+ ensureTemporaryQueueRegistered() {
169
+ if (this.isTemporaryQueueRegistered)
170
+ return;
171
+ try {
172
+ TrackPlayer.onTemporaryQueueChange((playNextQueue, upNextQueue) => {
173
+ this.temporaryQueueSubscribers.forEach((subscriber) => {
174
+ try {
175
+ subscriber(playNextQueue, upNextQueue);
176
+ }
177
+ catch (error) {
178
+ console.error('[CallbackManager] Error in temporary queue subscriber:', error);
179
+ }
180
+ });
181
+ });
182
+ this.isTemporaryQueueRegistered = true;
183
+ }
184
+ catch (error) {
185
+ console.error('[CallbackManager] Failed to register temporary queue callback:', error);
186
+ }
187
+ }
120
188
  ensureSeekRegistered() {
121
189
  if (this.isSeekRegistered)
122
190
  return;
@@ -137,6 +205,37 @@ class CallbackSubscriptionManager {
137
205
  console.error('[CallbackManager] Failed to register seek callback:', error);
138
206
  }
139
207
  }
208
+ /**
209
+ * Subscribe to in-stream (timed) metadata events
210
+ * @returns Unsubscribe function
211
+ */
212
+ subscribeToTimedMetadata(callback) {
213
+ this.timedMetadataSubscribers.add(callback);
214
+ this.ensureTimedMetadataRegistered();
215
+ return () => {
216
+ this.timedMetadataSubscribers.delete(callback);
217
+ };
218
+ }
219
+ ensureTimedMetadataRegistered() {
220
+ if (this.isTimedMetadataRegistered)
221
+ return;
222
+ try {
223
+ TrackPlayer.onTimedMetadata((metadata) => {
224
+ this.timedMetadataSubscribers.forEach((subscriber) => {
225
+ try {
226
+ subscriber(metadata);
227
+ }
228
+ catch (error) {
229
+ console.error('[CallbackManager] Error in timed metadata subscriber:', error);
230
+ }
231
+ });
232
+ });
233
+ this.isTimedMetadataRegistered = true;
234
+ }
235
+ catch (error) {
236
+ console.error('[CallbackManager] Failed to register timed metadata callback:', error);
237
+ }
238
+ }
140
239
  }
141
240
  // Export singleton instance
142
241
  export const callbackManager = new CallbackSubscriptionManager();
@@ -4,6 +4,7 @@ export { useOnPlaybackStateChange } from './useOnPlaybackStateChange';
4
4
  export type { PlaybackStateResult } from './useOnPlaybackStateChange';
5
5
  export { useOnSeek } from './useOnSeek';
6
6
  export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
7
+ export { useTimedMetadata } from './useTimedMetadata';
7
8
  export { useAndroidAutoConnection } from './useAndroidAutoConnection';
8
9
  export { useAudioDevices } from './useAudioDevices';
9
10
  export { useNowPlaying } from './useNowPlaying';
@@ -2,6 +2,7 @@ export { useOnChangeTrack } from './useOnChangeTrack';
2
2
  export { useOnPlaybackStateChange } from './useOnPlaybackStateChange';
3
3
  export { useOnSeek } from './useOnSeek';
4
4
  export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
5
+ export { useTimedMetadata } from './useTimedMetadata';
5
6
  export { useAndroidAutoConnection } from './useAndroidAutoConnection';
6
7
  export { useAudioDevices } from './useAudioDevices';
7
8
  export { useNowPlaying } from './useNowPlaying';
@@ -75,24 +75,30 @@ export function useActualQueue() {
75
75
  isMounted.current = false;
76
76
  };
77
77
  }, [updateQueue]);
78
- // Update queue on track changes (with slight delay to ensure native side has updated)
78
+ // Update queue on track changes.
79
+ //
80
+ // Deliberately NOT subscribed to playback-state changes: those fire several times
81
+ // per second while buffering or seeking, and each one used to pull the entire queue
82
+ // across the bridge. A playback state change never alters queue membership — only
83
+ // track changes and temporary-queue edits do.
79
84
  useEffect(() => {
80
- const unsubscribe = callbackManager.subscribeToTrackChange(() => {
81
- // Small delay to ensure native queue is updated
82
- setTimeout(updateQueue, 50);
85
+ return callbackManager.subscribeToTrackChange(() => {
86
+ updateQueue();
83
87
  });
84
- return () => {
85
- unsubscribe();
86
- };
87
88
  }, [updateQueue]);
88
- // Update queue on playback state changes
89
+ // Update queue when playNext / upNext change (native pushes the new lists).
89
90
  useEffect(() => {
90
- const unsubscribe = callbackManager.subscribeToPlaybackState(() => {
91
+ return callbackManager.subscribeToTemporaryQueueChange(() => {
92
+ updateQueue();
93
+ });
94
+ }, [updateQueue]);
95
+ // Update queue when the playlist itself is edited (track added / removed / reordered).
96
+ // Without this, a playlist mutation while paused would leave the queue stale until the
97
+ // next track change — playback-state changes used to paper over it.
98
+ useEffect(() => {
99
+ return callbackManager.subscribeToPlaylistsChanged(() => {
91
100
  updateQueue();
92
101
  });
93
- return () => {
94
- unsubscribe();
95
- };
96
102
  }, [updateQueue]);
97
103
  return { queue, refreshQueue, isLoading };
98
104
  }
@@ -80,12 +80,20 @@ export function useNowPlaying() {
80
80
  fetchFullState();
81
81
  });
82
82
  }, [fetchFullState]);
83
- // Subscribe to progress changes — lightweight position/duration update
83
+ // Subscribe to progress changes — lightweight position/duration update.
84
+ // Bail out when neither value changed at whole-second resolution: the native tick
85
+ // is sub-second, and a new state object on every tick re-renders every consumer.
84
86
  useEffect(() => {
85
87
  return callbackManager.subscribeToPlaybackProgressChange((currentPosition, totalDuration) => {
86
88
  if (!isMounted.current)
87
89
  return;
88
- setState((prev) => ({ ...prev, currentPosition, totalDuration }));
90
+ setState((prev) => {
91
+ if (Math.floor(prev.currentPosition) === Math.floor(currentPosition) &&
92
+ prev.totalDuration === totalDuration) {
93
+ return prev;
94
+ }
95
+ return { ...prev, currentPosition, totalDuration };
96
+ });
89
97
  });
90
98
  }, []);
91
99
  // Subscribe to seek events — lightweight position/duration update
@@ -0,0 +1,16 @@
1
+ import type { TimedMetadata } from '../types/PlayerQueue';
2
+ /**
3
+ * Hook to subscribe to in-stream (timed) metadata — ICY/Shoutcast StreamTitle
4
+ * updates and ID3 frames emitted while a live stream plays.
5
+ *
6
+ * @returns The most recent metadata, or undefined until the first event arrives
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * function NowPlayingFromStream() {
11
+ * const metadata = useTimedMetadata()
12
+ * return <Text>{metadata?.title ?? 'Live'}</Text>
13
+ * }
14
+ * ```
15
+ */
16
+ export declare function useTimedMetadata(): TimedMetadata | undefined;
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { callbackManager } from './callbackManager';
3
+ /**
4
+ * Hook to subscribe to in-stream (timed) metadata — ICY/Shoutcast StreamTitle
5
+ * updates and ID3 frames emitted while a live stream plays.
6
+ *
7
+ * @returns The most recent metadata, or undefined until the first event arrives
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * function NowPlayingFromStream() {
12
+ * const metadata = useTimedMetadata()
13
+ * return <Text>{metadata?.title ?? 'Live'}</Text>
14
+ * }
15
+ * ```
16
+ */
17
+ export function useTimedMetadata() {
18
+ const [metadata, setMetadata] = useState(undefined);
19
+ useEffect(() => callbackManager.subscribeToTimedMetadata(setMetadata), []);
20
+ return metadata;
21
+ }
@@ -1,5 +1,5 @@
1
1
  import type { HybridObject } from 'react-native-nitro-modules';
2
- import type { QueueOperation, Reason, TrackItem, TrackPlayerState, PlayerState, PlayerConfig, Playlist } from '../types/PlayerQueue';
2
+ import type { QueueOperation, Reason, TrackItem, TrackPlayerState, PlayerState, PlayerConfig, Playlist, TimedMetadata } from '../types/PlayerQueue';
3
3
  export interface PlayerQueue extends HybridObject<{
4
4
  android: 'kotlin';
5
5
  ios: 'swift';
@@ -41,6 +41,11 @@ export interface TrackPlayer extends HybridObject<{
41
41
  onPlaybackStateChange(callback: (state: TrackPlayerState, reason?: Reason) => void): void;
42
42
  onSeek(callback: (position: number, totalDuration: number) => void): void;
43
43
  onPlaybackProgressChange(callback: (position: number, totalDuration: number, isManuallySeeked?: boolean) => void): void;
44
+ /**
45
+ * Register callback for in-stream (timed) metadata — ICY/Shoutcast StreamTitle
46
+ * updates and ID3 frames emitted while a live stream plays.
47
+ */
48
+ onTimedMetadata(callback: (metadata: TimedMetadata) => void): void;
44
49
  onAndroidAutoConnectionChange(callback: (connected: boolean) => void): void;
45
50
  isAndroidAutoConnected(): boolean;
46
51
  setVolume(volume: number): Promise<void>;
@@ -29,6 +29,13 @@ export interface PlayerState {
29
29
  currentIndex: number;
30
30
  currentPlayingType: CurrentPlayingType;
31
31
  }
32
+ /** In-stream metadata emitted while a stream plays (ICY/Shoutcast StreamTitle, ID3 frames) */
33
+ export interface TimedMetadata {
34
+ title?: string;
35
+ artist?: string;
36
+ album?: string;
37
+ artworkUrl?: string;
38
+ }
32
39
  export interface PlayerConfig {
33
40
  androidAutoEnabled?: boolean;
34
41
  carPlayEnabled?: boolean;
@@ -35,6 +35,7 @@
35
35
  #include "JFunc_void_TrackPlayerState_std__optional_Reason_.hpp"
36
36
  #include "JFunc_void_double_double.hpp"
37
37
  #include "JFunc_void_double_double_std__optional_bool_.hpp"
38
+ #include "JFunc_void_TimedMetadata.hpp"
38
39
  #include "JFunc_void_std__vector_TrackItem__double.hpp"
39
40
  #include "JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp"
40
41
  #include <NitroModules/DefaultConstructableObject.hpp>
@@ -129,6 +130,7 @@ void registerAllNatives() {
129
130
  margelo::nitro::nitroplayer::JFunc_void_TrackPlayerState_std__optional_Reason__cxx::registerNatives();
130
131
  margelo::nitro::nitroplayer::JFunc_void_double_double_cxx::registerNatives();
131
132
  margelo::nitro::nitroplayer::JFunc_void_double_double_std__optional_bool__cxx::registerNatives();
133
+ margelo::nitro::nitroplayer::JFunc_void_TimedMetadata_cxx::registerNatives();
132
134
  margelo::nitro::nitroplayer::JFunc_void_std__vector_TrackItem__double_cxx::registerNatives();
133
135
  margelo::nitro::nitroplayer::JFunc_void_std__vector_TrackItem__std__vector_TrackItem__cxx::registerNatives();
134
136
 
@@ -0,0 +1,79 @@
1
+ ///
2
+ /// JFunc_void_TimedMetadata.hpp
3
+ /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
4
+ /// https://github.com/mrousavy/nitro
5
+ /// Copyright © Marc Rousavy @ Margelo
6
+ ///
7
+
8
+ #pragma once
9
+
10
+ #include <fbjni/fbjni.h>
11
+ #include <functional>
12
+
13
+ #include "TimedMetadata.hpp"
14
+ #include <functional>
15
+ #include <NitroModules/JNICallable.hpp>
16
+ #include "JTimedMetadata.hpp"
17
+ #include <string>
18
+ #include <optional>
19
+
20
+ namespace margelo::nitro::nitroplayer {
21
+
22
+ using namespace facebook;
23
+
24
+ /**
25
+ * Represents the Java/Kotlin callback `(metadata: TimedMetadata) -> Unit`.
26
+ * This can be passed around between C++ and Java/Kotlin.
27
+ */
28
+ struct JFunc_void_TimedMetadata: public jni::JavaClass<JFunc_void_TimedMetadata> {
29
+ public:
30
+ static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/nitroplayer/Func_void_TimedMetadata;";
31
+
32
+ public:
33
+ /**
34
+ * Invokes the function this `JFunc_void_TimedMetadata` instance holds through JNI.
35
+ */
36
+ void invoke(const TimedMetadata& metadata) const {
37
+ static const auto method = javaClassStatic()->getMethod<void(jni::alias_ref<JTimedMetadata> /* metadata */)>("invoke");
38
+ method(self(), JTimedMetadata::fromCpp(metadata));
39
+ }
40
+ };
41
+
42
+ /**
43
+ * An implementation of Func_void_TimedMetadata that is backed by a C++ implementation (using `std::function<...>`)
44
+ */
45
+ class JFunc_void_TimedMetadata_cxx final: public jni::HybridClass<JFunc_void_TimedMetadata_cxx, JFunc_void_TimedMetadata> {
46
+ public:
47
+ static jni::local_ref<JFunc_void_TimedMetadata::javaobject> fromCpp(const std::function<void(const TimedMetadata& /* metadata */)>& func) {
48
+ return JFunc_void_TimedMetadata_cxx::newObjectCxxArgs(func);
49
+ }
50
+
51
+ public:
52
+ /**
53
+ * Invokes the C++ `std::function<...>` this `JFunc_void_TimedMetadata_cxx` instance holds.
54
+ */
55
+ void invoke_cxx(jni::alias_ref<JTimedMetadata> metadata) {
56
+ _func(metadata->toCpp());
57
+ }
58
+
59
+ public:
60
+ [[nodiscard]]
61
+ inline const std::function<void(const TimedMetadata& /* metadata */)>& getFunction() const {
62
+ return _func;
63
+ }
64
+
65
+ public:
66
+ static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/nitroplayer/Func_void_TimedMetadata_cxx;";
67
+ static void registerNatives() {
68
+ registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_TimedMetadata_cxx::invoke_cxx)});
69
+ }
70
+
71
+ private:
72
+ explicit JFunc_void_TimedMetadata_cxx(const std::function<void(const TimedMetadata& /* metadata */)>& func): _func(func) { }
73
+
74
+ private:
75
+ friend HybridBase;
76
+ std::function<void(const TimedMetadata& /* metadata */)> _func;
77
+ };
78
+
79
+ } // namespace margelo::nitro::nitroplayer
@@ -21,6 +21,8 @@ namespace margelo::nitro::nitroplayer { enum class RepeatMode; }
21
21
  namespace margelo::nitro::nitroplayer { struct PlayerConfig; }
22
22
  // Forward declaration of `Reason` to properly resolve imports.
23
23
  namespace margelo::nitro::nitroplayer { enum class Reason; }
24
+ // Forward declaration of `TimedMetadata` to properly resolve imports.
25
+ namespace margelo::nitro::nitroplayer { struct TimedMetadata; }
24
26
 
25
27
  #include <NitroModules/Promise.hpp>
26
28
  #include <NitroModules/JPromise.hpp>
@@ -55,6 +57,9 @@ namespace margelo::nitro::nitroplayer { enum class Reason; }
55
57
  #include "JFunc_void_TrackPlayerState_std__optional_Reason_.hpp"
56
58
  #include "JFunc_void_double_double.hpp"
57
59
  #include "JFunc_void_double_double_std__optional_bool_.hpp"
60
+ #include "TimedMetadata.hpp"
61
+ #include "JFunc_void_TimedMetadata.hpp"
62
+ #include "JTimedMetadata.hpp"
58
63
  #include "JFunc_void_bool.hpp"
59
64
  #include "JFunc_void_std__vector_TrackItem__double.hpp"
60
65
  #include "JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp"
@@ -320,6 +325,10 @@ namespace margelo::nitro::nitroplayer {
320
325
  static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_double_double_std__optional_bool_::javaobject> /* callback */)>("onPlaybackProgressChange_cxx");
321
326
  method(_javaPart, JFunc_void_double_double_std__optional_bool__cxx::fromCpp(callback));
322
327
  }
328
+ void JHybridTrackPlayerSpec::onTimedMetadata(const std::function<void(const TimedMetadata& /* metadata */)>& callback) {
329
+ static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_TimedMetadata::javaobject> /* callback */)>("onTimedMetadata_cxx");
330
+ method(_javaPart, JFunc_void_TimedMetadata_cxx::fromCpp(callback));
331
+ }
323
332
  void JHybridTrackPlayerSpec::onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) {
324
333
  static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_bool::javaobject> /* callback */)>("onAndroidAutoConnectionChange_cxx");
325
334
  method(_javaPart, JFunc_void_bool_cxx::fromCpp(callback));