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.
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +58 -29
- package/ios/core/TrackPlayerCore.swift +22 -1
- package/ios/core/TrackPlayerListener.swift +84 -5
- package/ios/core/TrackPlayerNotify.swift +13 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +27 -1
- package/lib/hooks/callbackManager.js +100 -1
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/lib/hooks/useTimedMetadata.d.ts +16 -0
- package/lib/hooks/useTimedMetadata.js +21 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
- package/lib/types/PlayerQueue.d.ts +7 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
- package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +134 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
- package/src/hooks/useTimedMetadata.ts +25 -0
- package/src/specs/TrackPlayer.nitro.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
63
|
+
func updateFromPlayerQueue(
|
|
64
|
+
track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
|
|
65
|
+
) {
|
|
60
66
|
cachedTrack = track
|
|
61
67
|
cachedState = state
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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();
|
package/lib/hooks/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/hooks/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
setTimeout(updateQueue, 50);
|
|
85
|
+
return callbackManager.subscribeToTrackChange(() => {
|
|
86
|
+
updateQueue();
|
|
83
87
|
});
|
|
84
|
-
return () => {
|
|
85
|
-
unsubscribe();
|
|
86
|
-
};
|
|
87
88
|
}, [updateQueue]);
|
|
88
|
-
// Update queue
|
|
89
|
+
// Update queue when playNext / upNext change (native pushes the new lists).
|
|
89
90
|
useEffect(() => {
|
|
90
|
-
|
|
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) =>
|
|
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));
|