react-native-nitro-player 1.5.1-alpha.0 → 1.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +6 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +7 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +32 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  5. package/ios/HybridTrackPlayer.swift +6 -0
  6. package/ios/core/TrackPlayerCore.swift +13 -0
  7. package/ios/core/TrackPlayerListener.swift +89 -0
  8. package/ios/core/TrackPlayerNotify.swift +4 -0
  9. package/lib/hooks/callbackManager.d.ts +10 -1
  10. package/lib/hooks/callbackManager.js +33 -0
  11. package/lib/hooks/index.d.ts +1 -0
  12. package/lib/hooks/index.js +1 -0
  13. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  14. package/lib/hooks/useTimedMetadata.js +21 -0
  15. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  16. package/lib/types/PlayerQueue.d.ts +15 -0
  17. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  18. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  19. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  20. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  21. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +74 -0
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  24. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +50 -0
  25. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  26. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  27. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  28. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  29. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  30. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  31. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  32. package/nitrogen/generated/ios/swift/TimedMetadata.swift +114 -0
  33. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  34. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  35. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +100 -0
  36. package/package.json +1 -1
  37. package/src/hooks/callbackManager.ts +47 -1
  38. package/src/hooks/index.ts +1 -0
  39. package/src/hooks/useTimedMetadata.ts +25 -0
  40. package/src/specs/TrackPlayer.nitro.ts +6 -0
  41. package/src/types/PlayerQueue.ts +16 -0
@@ -186,6 +186,11 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
186
186
  listenerIds += "onTracksNeedUpdate" to id
187
187
  }
188
188
 
189
+ override fun onTimedMetadata(callback: (metadata: TimedMetadata) -> Unit) {
190
+ val id = core.addOnTimedMetadataListener(callback)
191
+ listenerIds += "onTimedMetadata" to id
192
+ }
193
+
189
194
  override fun onTemporaryQueueChange(callback: (playNextQueue: Array<TrackItem>, upNextQueue: Array<TrackItem>) -> Unit) {
190
195
  val id =
191
196
  core.addOnTemporaryQueueChangeListener { pn, un ->
@@ -207,6 +212,7 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
207
212
  "onAndroidAutoConnectionChange" -> core.removeOnAndroidAutoConnectionListener(id)
208
213
  "onTracksNeedUpdate" -> core.removeOnTracksNeedUpdateListener(id)
209
214
  "onTemporaryQueueChange" -> core.removeOnTemporaryQueueChangeListener(id)
215
+ "onTimedMetadata" -> core.removeOnTimedMetadataListener(id)
210
216
  }
211
217
  }
212
218
  listenerIds.clear()
@@ -11,6 +11,7 @@ import android.os.IBinder
11
11
  import android.os.Looper
12
12
  import com.margelo.nitro.nitroplayer.Reason
13
13
  import com.margelo.nitro.nitroplayer.RepeatMode
14
+ import com.margelo.nitro.nitroplayer.TimedMetadata
14
15
  import com.margelo.nitro.nitroplayer.TrackItem
15
16
  import com.margelo.nitro.nitroplayer.TrackPlayerState
16
17
  import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
@@ -90,6 +91,8 @@ class TrackPlayerCore private constructor(
90
91
  ListenerRegistry<(List<TrackItem>, List<TrackItem>) -> Unit>()
91
92
  internal val onAndroidAutoConnectionListeners =
92
93
  ListenerRegistry<(Boolean) -> Unit>()
94
+ internal val onTimedMetadataListeners =
95
+ ListenerRegistry<(TimedMetadata) -> Unit>()
93
96
  internal val onCastStateChangeListeners =
94
97
  ListenerRegistry<(com.margelo.nitro.nitroplayer.CastState, String?) -> Unit>()
95
98
 
@@ -376,6 +379,10 @@ class TrackPlayerCore private constructor(
376
379
 
377
380
  fun removeOnTemporaryQueueChangeListener(id: Long): Boolean = onTemporaryQueueChangeListeners.remove(id)
378
381
 
382
+ fun addOnTimedMetadataListener(cb: (TimedMetadata) -> Unit): Long = onTimedMetadataListeners.add(cb)
383
+
384
+ fun removeOnTimedMetadataListener(id: Long): Boolean = onTimedMetadataListeners.remove(id)
385
+
379
386
  fun addOnAndroidAutoConnectionListener(cb: (Boolean) -> Unit): Long = onAndroidAutoConnectionListeners.add(cb)
380
387
 
381
388
  fun removeOnAndroidAutoConnectionListener(id: Long): Boolean = onAndroidAutoConnectionListeners.remove(id)
@@ -3,9 +3,14 @@
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import androidx.media3.common.MediaItem
6
+ import androidx.media3.common.MediaMetadata
7
+ import androidx.media3.common.Metadata
6
8
  import androidx.media3.common.Player
9
+ import androidx.media3.common.util.UnstableApi
10
+ import androidx.media3.extractor.metadata.icy.IcyInfo
7
11
  import com.margelo.nitro.nitroplayer.Reason
8
12
  import com.margelo.nitro.nitroplayer.RepeatMode
13
+ import com.margelo.nitro.nitroplayer.TimedMetadata
9
14
  import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
10
15
  import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
11
16
 
@@ -82,6 +87,33 @@ internal class TrackPlayerEventListener(
82
87
  }
83
88
  }
84
89
 
90
+ /** In-stream metadata (ICY StreamTitle, ID3 frames) arriving mid-playback. */
91
+ @UnstableApi
92
+ override fun onMetadata(metadata: Metadata) {
93
+ val builder = MediaMetadata.Builder()
94
+ var url: String? = null
95
+ for (i in 0 until metadata.length()) {
96
+ val entry = metadata.get(i)
97
+ entry.populateMediaMetadata(builder)
98
+ // populateMediaMetadata drops IcyInfo.url — radio providers put the
99
+ // current track's artwork URL there, so read it off the entry directly.
100
+ if (entry is IcyInfo && !entry.url.isNullOrEmpty()) url = entry.url
101
+ }
102
+ val merged = builder.build()
103
+ var artist = merged.artist?.toString()
104
+ var title = merged.title?.toString()
105
+ // ICY streams pack both fields into one "Artist - Title" string
106
+ val separator = if (artist == null) title?.indexOf(" - ") ?: -1 else -1
107
+ if (separator > 0 && title != null && separator + 3 < title.length) {
108
+ artist = title.substring(0, separator)
109
+ title = title.substring(separator + 3)
110
+ }
111
+ val album = merged.albumTitle?.toString()
112
+ val artworkUrl = merged.artworkUri?.toString()
113
+ if (title == null && artist == null && album == null && url == null && artworkUrl == null) return
114
+ core.notifyTimedMetadata(TimedMetadata(title, artist, album, url, artworkUrl))
115
+ }
116
+
85
117
  override fun onTimelineChanged(
86
118
  timeline: androidx.media3.common.Timeline,
87
119
  reason: Int,
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.nitroplayer.core
2
2
 
3
3
  import com.margelo.nitro.nitroplayer.Reason
4
+ import com.margelo.nitro.nitroplayer.TimedMetadata
4
5
  import com.margelo.nitro.nitroplayer.TrackItem
5
6
  import com.margelo.nitro.nitroplayer.TrackPlayerState
6
7
 
@@ -45,6 +46,10 @@ internal fun TrackPlayerCore.notifyTracksNeedUpdate(
45
46
  onTracksNeedUpdateListeners.forEach { it(tracks, lookahead) }
46
47
  }
47
48
 
49
+ internal fun TrackPlayerCore.notifyTimedMetadata(metadata: TimedMetadata) {
50
+ onTimedMetadataListeners.forEach { it(metadata) }
51
+ }
52
+
48
53
  internal fun TrackPlayerCore.notifyTemporaryQueueChange() {
49
54
  val pn = playNextStack.toList()
50
55
  val un = upNextQueue.toList()
@@ -219,6 +219,11 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
219
219
  listenerIds.append(("onTracksNeedUpdate", id))
220
220
  }
221
221
 
222
+ func onTimedMetadata(callback: @escaping (_ metadata: TimedMetadata) -> Void) throws {
223
+ let id = core.addOnTimedMetadataListener(callback)
224
+ listenerIds.append(("onTimedMetadata", id))
225
+ }
226
+
222
227
  func onTemporaryQueueChange(callback: @escaping (_ playNextQueue: [TrackItem], _ upNextQueue: [TrackItem]) -> Void) throws {
223
228
  let id = core.addOnTemporaryQueueChangeListener(callback)
224
229
  listenerIds.append(("onTemporaryQueueChange", id))
@@ -235,6 +240,7 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
235
240
  case "onPlaybackProgressChange":_ = core.removeOnProgressListener(id: id)
236
241
  case "onTracksNeedUpdate": _ = core.removeOnTracksNeedUpdateListener(id: id)
237
242
  case "onTemporaryQueueChange": _ = core.removeOnTemporaryQueueChangeListener(id: id)
243
+ case "onTimedMetadata": _ = core.removeOnTimedMetadataListener(id: id)
238
244
  default: break
239
245
  }
240
246
  }
@@ -59,6 +59,10 @@ class TrackPlayerCore: NSObject {
59
59
  internal var boundaryTimeObserver: Any?
60
60
  internal var currentItemObservers: [NSKeyValueObservation] = []
61
61
 
62
+ // In-stream (timed) metadata output, attached to the current item only.
63
+ internal var metadataOutput: AVPlayerItemMetadataOutput?
64
+ internal weak var metadataOutputItem: AVPlayerItem?
65
+
62
66
  // Gapless playback
63
67
  internal var preloadedAssets: [String: AVURLAsset] = [:]
64
68
  internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
@@ -110,6 +114,7 @@ class TrackPlayerCore: NSObject {
110
114
  internal let onProgressListeners = ListenerRegistry<(Double, Double, Bool?) -> Void>()
111
115
  internal let onTracksNeedUpdateListeners = ListenerRegistry<([TrackItem], Int) -> Void>()
112
116
  internal let onTemporaryQueueChangeListeners = ListenerRegistry<([TrackItem], [TrackItem]) -> Void>()
117
+ internal let onTimedMetadataListeners = ListenerRegistry<(TimedMetadata) -> Void>()
113
118
  internal let onCastStateChangeListeners = ListenerRegistry<(CastState, String?) -> Void>()
114
119
 
115
120
  // MARK: - Google Cast
@@ -216,6 +221,13 @@ class TrackPlayerCore: NSObject {
216
221
  onTemporaryQueueChangeListeners.remove(id: id)
217
222
  }
218
223
 
224
+ @discardableResult func addOnTimedMetadataListener(_ cb: @escaping (TimedMetadata) -> Void) -> Int64 {
225
+ onTimedMetadataListeners.add(cb)
226
+ }
227
+ @discardableResult func removeOnTimedMetadataListener(id: Int64) -> Bool {
228
+ onTimedMetadataListeners.remove(id: id)
229
+ }
230
+
219
231
  @discardableResult func addOnCastStateChangeListener(_ cb: @escaping (CastState, String?) -> Void) -> Int64 {
220
232
  onCastStateChangeListeners.add(cb)
221
233
  }
@@ -237,6 +249,7 @@ class TrackPlayerCore: NSObject {
237
249
  p.removeTimeObserver(obs)
238
250
  }
239
251
  self.currentItemObservers.removeAll()
252
+ self.detachMetadataOutput()
240
253
  if let p = self.player {
241
254
  p.removeObserver(self, forKeyPath: "status")
242
255
  p.removeObserver(self, forKeyPath: "rate")
@@ -285,6 +285,7 @@ extension TrackPlayerCore {
285
285
 
286
286
  guard let player, let currentItem = player.currentItem else {
287
287
  NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
288
+ detachMetadataOutput()
288
289
  // Queue exhausted — handle PLAYLIST repeat
289
290
  if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = self.player {
290
291
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
@@ -414,9 +415,33 @@ extension TrackPlayerCore {
414
415
  cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
415
416
  }
416
417
 
418
+ /// Attaches an `AVPlayerItemMetadataOutput` to the current item so ICY/ID3
419
+ /// metadata emitted mid-stream reaches JS. Detaches from the previous item first —
420
+ /// an output may only be attached to one item at a time.
421
+ func attachMetadataOutput(to item: AVPlayerItem) {
422
+ detachMetadataOutput()
423
+ let output = AVPlayerItemMetadataOutput(identifiers: nil)
424
+ output.setDelegate(self, queue: playerQueue)
425
+ item.add(output)
426
+ metadataOutput = output
427
+ metadataOutputItem = item
428
+ }
429
+
430
+ /// Clears the metadata output so a detached one can no longer pass the identity
431
+ /// check in the delegate and emit metadata for an item that is no longer playing.
432
+ func detachMetadataOutput() {
433
+ if let output = metadataOutput, let item = metadataOutputItem {
434
+ item.remove(output)
435
+ }
436
+ metadataOutput = nil
437
+ metadataOutputItem = nil
438
+ }
439
+
417
440
  func setupCurrentItemObservers(item: AVPlayerItem) {
418
441
  NitroPlayerLogger.log("TrackPlayerCore", "📱 Setting up item observers")
419
442
 
443
+ attachMetadataOutput(to: item)
444
+
420
445
  let statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
421
446
  self?.playerQueue.async {
422
447
  if item.status == .readyToPlay {
@@ -481,6 +506,56 @@ extension TrackPlayerCore {
481
506
  currentItemObservers.append(bufferKeepUpObserver)
482
507
  }
483
508
 
509
+ func handleTimedMetadataGroups(_ groups: [AVTimedMetadataGroup]) {
510
+ var title: String?
511
+ var artist: String?
512
+ var album: String?
513
+ var url: String?
514
+ var artworkUrl: String?
515
+
516
+ for group in groups {
517
+ for item in group.items {
518
+ guard let value = item.stringValue, !value.isEmpty else { continue }
519
+ switch item.commonKey {
520
+ case .some(.commonKeyTitle): title = value
521
+ case .some(.commonKeyArtist): artist = value
522
+ case .some(.commonKeyAlbumName): album = value
523
+ case .some(.commonKeyArtwork): artworkUrl = value
524
+ default:
525
+ // ICY/Shoutcast streams carry no common keys — match on the identifier.
526
+ // StreamUrl is where radio providers put the current track's artwork.
527
+ switch item.identifier {
528
+ case .some(.icyMetadataStreamTitle): title = value
529
+ case .some(.icyMetadataStreamURL): url = value
530
+ case .some(.id3MetadataOfficialAudioSourceWebpage),
531
+ .some(.id3MetadataOfficialAudioFileWebpage):
532
+ if url == nil { url = value }
533
+ default: break
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ (artist, title) = splitStreamTitle(artist: artist, title: title)
540
+
541
+ guard title != nil || artist != nil || album != nil || url != nil || artworkUrl != nil else { return }
542
+ NitroPlayerLogger.log("TrackPlayerCore", "🏷️ Timed metadata - \(title ?? "-") / \(artist ?? "-")")
543
+ notifyTimedMetadata(
544
+ TimedMetadata(title: title, artist: artist, album: album, url: url, artworkUrl: artworkUrl))
545
+ }
546
+
547
+ /// ICY streams pack both fields into one `"Artist - Title"` string. Split it when
548
+ /// the stream gave no separate artist, leaving an explicit artist untouched.
549
+ func splitStreamTitle(artist: String?, title: String?) -> (String?, String?) {
550
+ guard artist == nil, let title, let separator = title.range(of: " - ") else {
551
+ return (artist, title)
552
+ }
553
+ let left = String(title[title.startIndex..<separator.lowerBound])
554
+ let right = String(title[separator.upperBound...])
555
+ guard !left.isEmpty, !right.isEmpty else { return (artist, title) }
556
+ return (left, right)
557
+ }
558
+
484
559
  func emitStateChange(reason: Reason? = nil) {
485
560
  guard let player else { return }
486
561
  let state: TrackPlayerState
@@ -501,3 +576,17 @@ extension TrackPlayerCore {
501
576
  notifyPlaybackStateChange(state, reason)
502
577
  }
503
578
  }
579
+
580
+ extension TrackPlayerCore: AVPlayerItemMetadataOutputPushDelegate {
581
+ // Delivered on playerQueue (set in attachMetadataOutput)
582
+ func metadataOutput(
583
+ _ output: AVPlayerItemMetadataOutput,
584
+ didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
585
+ from track: AVPlayerItemTrack?
586
+ ) {
587
+ // A detached output can still flush metadata it had already buffered — ignore it,
588
+ // it belongs to an item that is no longer current.
589
+ guard output === metadataOutput else { return }
590
+ handleTimedMetadataGroups(groups)
591
+ }
592
+ }
@@ -46,6 +46,10 @@ extension TrackPlayerCore {
46
46
  onTracksNeedUpdateListeners.forEach { $0(tracks, lookahead) }
47
47
  }
48
48
 
49
+ func notifyTimedMetadata(_ metadata: TimedMetadata) {
50
+ onTimedMetadataListeners.forEach { $0(metadata) }
51
+ }
52
+
49
53
  func notifyTemporaryQueueChange() {
50
54
  let pn = playNextStack
51
55
  let un = upNextQueue
@@ -1,8 +1,9 @@
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;
6
7
  type TemporaryQueueCallback = (playNextQueue: TrackItem[], upNextQueue: TrackItem[]) => void;
7
8
  /**
8
9
  * Internal subscription manager that allows multiple hooks to subscribe
@@ -14,6 +15,7 @@ declare class CallbackSubscriptionManager {
14
15
  private trackChangeSubscribers;
15
16
  private playbackProgressSubscribers;
16
17
  private seekSubscribers;
18
+ private timedMetadataSubscribers;
17
19
  private temporaryQueueSubscribers;
18
20
  private playlistsChangedSubscribers;
19
21
  private isPlaylistsChangedRegistered;
@@ -21,6 +23,7 @@ declare class CallbackSubscriptionManager {
21
23
  private isTrackChangeRegistered;
22
24
  private isPlaybackProgressRegistered;
23
25
  private isSeekRegistered;
26
+ private isTimedMetadataRegistered;
24
27
  private isTemporaryQueueRegistered;
25
28
  /**
26
29
  * Subscribe to playback state changes
@@ -58,6 +61,12 @@ declare class CallbackSubscriptionManager {
58
61
  private ensurePlaylistsChangedRegistered;
59
62
  private ensureTemporaryQueueRegistered;
60
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;
61
70
  }
62
71
  export declare const callbackManager: CallbackSubscriptionManager;
63
72
  export {};
@@ -9,6 +9,7 @@ class CallbackSubscriptionManager {
9
9
  trackChangeSubscribers = new Set();
10
10
  playbackProgressSubscribers = new Set();
11
11
  seekSubscribers = new Set();
12
+ timedMetadataSubscribers = new Set();
12
13
  temporaryQueueSubscribers = new Set();
13
14
  playlistsChangedSubscribers = new Set();
14
15
  isPlaylistsChangedRegistered = false;
@@ -16,6 +17,7 @@ class CallbackSubscriptionManager {
16
17
  isTrackChangeRegistered = false;
17
18
  isPlaybackProgressRegistered = false;
18
19
  isSeekRegistered = false;
20
+ isTimedMetadataRegistered = false;
19
21
  isTemporaryQueueRegistered = false;
20
22
  /**
21
23
  * Subscribe to playback state changes
@@ -203,6 +205,37 @@ class CallbackSubscriptionManager {
203
205
  console.error('[CallbackManager] Failed to register seek callback:', error);
204
206
  }
205
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
+ }
206
239
  }
207
240
  // Export singleton instance
208
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';
@@ -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,21 @@ 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
+ /** Split out of `title` when the stream sends only `"Artist - Title"` */
36
+ artist?: string;
37
+ album?: string;
38
+ /**
39
+ * ICY `StreamUrl` / ID3 webpage frame. Radio providers commonly put the
40
+ * current track's artwork URL here (Live365, StreamGuys), but some send a
41
+ * station or artist page instead — check the value before treating it as art.
42
+ */
43
+ url?: string;
44
+ /** Artwork URL the stream tagged as artwork, when it sends one explicitly */
45
+ artworkUrl?: string;
46
+ }
32
47
  export interface PlayerConfig {
33
48
  androidAutoEnabled?: boolean;
34
49
  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));
@@ -72,6 +72,7 @@ namespace margelo::nitro::nitroplayer {
72
72
  void onPlaybackStateChange(const std::function<void(TrackPlayerState /* state */, std::optional<Reason> /* reason */)>& callback) override;
73
73
  void onSeek(const std::function<void(double /* position */, double /* totalDuration */)>& callback) override;
74
74
  void onPlaybackProgressChange(const std::function<void(double /* position */, double /* totalDuration */, std::optional<bool> /* isManuallySeeked */)>& callback) override;
75
+ void onTimedMetadata(const std::function<void(const TimedMetadata& /* metadata */)>& callback) override;
75
76
  void onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) override;
76
77
  bool isAndroidAutoConnected() override;
77
78
  std::shared_ptr<Promise<void>> setVolume(double volume) override;