react-native-nitro-player 1.4.3 → 1.5.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/NitroPlayer.podspec +6 -0
- package/README.md +113 -0
- package/android/build.gradle +8 -0
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridCast.kt +79 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +10 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +122 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +7 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +160 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastConfig.kt +23 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastOptionsProvider.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroMediaItemConverter.kt +53 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +33 -0
- package/ios/HybridCast.swift +63 -0
- package/ios/core/CastSessionManager.swift +367 -0
- package/ios/core/TrackPlayerCast.swift +109 -0
- package/ios/core/TrackPlayerCore.swift +28 -2
- package/ios/core/TrackPlayerPlayback.swift +32 -0
- package/ios/core/TrackPlayerQueue.swift +8 -1
- package/ios/core/TrackPlayerQueueBuild.swift +37 -2
- package/ios/core/TrackPlayerRedirectResolver.swift +150 -0
- package/lib/components/CastButton.d.ts +44 -0
- package/lib/components/CastButton.js +100 -0
- package/lib/hooks/castCallbackManager.d.ts +19 -0
- package/lib/hooks/castCallbackManager.js +43 -0
- package/lib/hooks/index.d.ts +3 -0
- package/lib/hooks/index.js +3 -0
- package/lib/hooks/useCastState.d.ts +27 -0
- package/lib/hooks/useCastState.js +49 -0
- package/lib/hooks/useIsCasting.d.ts +17 -0
- package/lib/hooks/useIsCasting.js +43 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +4 -1
- package/lib/specs/Cast.nitro.d.ts +58 -0
- package/lib/specs/Cast.nitro.js +1 -0
- package/nitro.json +10 -0
- package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +3 -1
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +18 -0
- package/nitrogen/generated/android/c++/JCastState.hpp +64 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp +83 -0
- package/nitrogen/generated/android/c++/JHybridCastSpec.cpp +115 -0
- package/nitrogen/generated/android/c++/JHybridCastSpec.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/CastState.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridCastSpec.kt +85 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +38 -13
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +176 -125
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +8 -0
- package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +8 -0
- package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +12 -0
- package/nitrogen/generated/ios/c++/HybridCastSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridCastSpecSwift.hpp +133 -0
- package/nitrogen/generated/ios/swift/CastState.swift +48 -0
- package/nitrogen/generated/ios/swift/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.swift +65 -0
- package/nitrogen/generated/ios/swift/HybridCastSpec.swift +61 -0
- package/nitrogen/generated/ios/swift/HybridCastSpec_cxx.swift +254 -0
- package/nitrogen/generated/shared/c++/CastState.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridCastSpec.cpp +27 -0
- package/nitrogen/generated/shared/c++/HybridCastSpec.hpp +75 -0
- package/package.json +3 -3
- package/src/components/CastButton.tsx +196 -0
- package/src/hooks/castCallbackManager.ts +55 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useCastState.ts +67 -0
- package/src/hooks/useIsCasting.ts +47 -0
- package/src/index.ts +9 -0
- package/src/specs/Cast.nitro.ts +72 -0
|
@@ -57,6 +57,10 @@ class TrackPlayerCore: NSObject {
|
|
|
57
57
|
internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
58
58
|
internal var didRequestUrlsForCurrentItem = false
|
|
59
59
|
|
|
60
|
+
// Follows http→https redirects that AVURLAsset otherwise silently drops (issue #111).
|
|
61
|
+
// Attached as the resource-loader delegate for cleartext-http assets only.
|
|
62
|
+
internal let redirectResolver = TrackPlayerRedirectResolver()
|
|
63
|
+
|
|
60
64
|
// MARK: - Stall & network recovery
|
|
61
65
|
// Whether the user/app wants playback ongoing (true after play(), false after pause()).
|
|
62
66
|
internal var intendedToPlay = false
|
|
@@ -99,6 +103,14 @@ class TrackPlayerCore: NSObject {
|
|
|
99
103
|
internal let onProgressListeners = ListenerRegistry<(Double, Double, Bool?) -> Void>()
|
|
100
104
|
internal let onTracksNeedUpdateListeners = ListenerRegistry<([TrackItem], Int) -> Void>()
|
|
101
105
|
internal let onTemporaryQueueChangeListeners = ListenerRegistry<([TrackItem], [TrackItem]) -> Void>()
|
|
106
|
+
internal let onCastStateChangeListeners = ListenerRegistry<(CastState, String?) -> Void>()
|
|
107
|
+
|
|
108
|
+
// MARK: - Google Cast
|
|
109
|
+
/// Owns the GoogleCast session; created on the main thread in init. Cast features
|
|
110
|
+
/// are inert no-ops if the GoogleCast SDK is not linked.
|
|
111
|
+
internal var castManager: CastSessionManager?
|
|
112
|
+
/// True while playback is routed to a Cast device (audio plays only on the device).
|
|
113
|
+
internal var isCasting = false
|
|
102
114
|
|
|
103
115
|
// MARK: - Singleton
|
|
104
116
|
static let shared = TrackPlayerCore()
|
|
@@ -112,8 +124,14 @@ class TrackPlayerCore: NSObject {
|
|
|
112
124
|
self?.setupPlayer()
|
|
113
125
|
}
|
|
114
126
|
DispatchQueue.main.async { [weak self] in
|
|
115
|
-
self
|
|
116
|
-
self
|
|
127
|
+
guard let self else { return }
|
|
128
|
+
self.mediaSessionManager = MediaSessionManager()
|
|
129
|
+
self.mediaSessionManager?.setTrackPlayerCore(self)
|
|
130
|
+
// Initialize Cast with the Default Media Receiver. Apps can override the
|
|
131
|
+
// receiver ID early via Cast.configure(id). No-op without the GoogleCast SDK.
|
|
132
|
+
self.castManager = CastSessionManager()
|
|
133
|
+
self.castManager?.core = self
|
|
134
|
+
self.castManager?.configure(receiverApplicationId: nil)
|
|
117
135
|
}
|
|
118
136
|
}
|
|
119
137
|
|
|
@@ -191,6 +209,13 @@ class TrackPlayerCore: NSObject {
|
|
|
191
209
|
onTemporaryQueueChangeListeners.remove(id: id)
|
|
192
210
|
}
|
|
193
211
|
|
|
212
|
+
@discardableResult func addOnCastStateChangeListener(_ cb: @escaping (CastState, String?) -> Void) -> Int64 {
|
|
213
|
+
onCastStateChangeListeners.add(cb)
|
|
214
|
+
}
|
|
215
|
+
@discardableResult func removeOnCastStateChangeListener(id: Int64) -> Bool {
|
|
216
|
+
onCastStateChangeListeners.remove(id: id)
|
|
217
|
+
}
|
|
218
|
+
|
|
194
219
|
// MARK: - Simple accessors
|
|
195
220
|
func getCurrentPlaylistId() -> String? { currentPlaylistId }
|
|
196
221
|
func getPlaylistManager() -> PlaylistManager { playlistManager }
|
|
@@ -216,6 +241,7 @@ class TrackPlayerCore: NSObject {
|
|
|
216
241
|
self.pathMonitor = nil
|
|
217
242
|
self.preloadedAssets.removeAll()
|
|
218
243
|
self.failedItemRetryCounts.removeAll()
|
|
244
|
+
self.redirectResolver.clear()
|
|
219
245
|
}
|
|
220
246
|
}
|
|
221
247
|
|
|
@@ -12,22 +12,44 @@ import MediaPlayer
|
|
|
12
12
|
extension TrackPlayerCore {
|
|
13
13
|
|
|
14
14
|
func play() async {
|
|
15
|
+
if isCasting {
|
|
16
|
+
intendedToPlay = true
|
|
17
|
+
castManager?.play()
|
|
18
|
+
return
|
|
19
|
+
}
|
|
15
20
|
await withPlayerQueueNoThrow { self.playInternal() }
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
func pause() async {
|
|
24
|
+
if isCasting {
|
|
25
|
+
intendedToPlay = false
|
|
26
|
+
castManager?.pause()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
19
29
|
await withPlayerQueueNoThrow { self.pauseInternal() }
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
func seek(position: Double) async {
|
|
33
|
+
if isCasting {
|
|
34
|
+
castManager?.seek(to: position)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
23
37
|
await withPlayerQueueNoThrow { self.seekInternal(position: position) }
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
func skipToNext() async {
|
|
41
|
+
if isCasting {
|
|
42
|
+
castManager?.skipToNext()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
27
45
|
await withPlayerQueueNoThrow { self.skipToNextInternal() }
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
func skipToPrevious() async {
|
|
49
|
+
if isCasting {
|
|
50
|
+
castManager?.skipToPrevious()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
31
53
|
await withPlayerQueueNoThrow { self.skipToPreviousInternal() }
|
|
32
54
|
}
|
|
33
55
|
|
|
@@ -40,6 +62,11 @@ extension TrackPlayerCore {
|
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
func setVolume(volume: Double) async {
|
|
65
|
+
if isCasting {
|
|
66
|
+
let clamped = max(0.0, min(100.0, volume))
|
|
67
|
+
castManager?.setVolume(Float(clamped / 100.0))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
43
70
|
await withPlayerQueueNoThrow {
|
|
44
71
|
let clamped = max(0.0, min(100.0, volume))
|
|
45
72
|
let normalized = Float(clamped / 100.0)
|
|
@@ -65,6 +92,11 @@ extension TrackPlayerCore {
|
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
func setPlaybackSpeed(_ speed: Double) async {
|
|
95
|
+
if isCasting {
|
|
96
|
+
currentPlaybackSpeed = speed
|
|
97
|
+
castManager?.setPlaybackRate(Float(speed))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
68
100
|
await withPlayerQueueNoThrow {
|
|
69
101
|
self.currentPlaybackSpeed = speed
|
|
70
102
|
// Only update rate if currently playing; pause keeps rate at 0 until play() is called
|
|
@@ -19,7 +19,14 @@ extension TrackPlayerCore {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
func skipToIndex(index: Int) async -> Bool {
|
|
22
|
-
|
|
22
|
+
if isCasting {
|
|
23
|
+
return await withPlayerQueueNoThrow {
|
|
24
|
+
guard index >= 0 && index < self.getActualQueueInternal().count else { return false }
|
|
25
|
+
self.castManager?.jump(toQueueIndex: index)
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return await withPlayerQueueNoThrow { self.skipToIndexInternal(index: index) }
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
func playFromIndex(index: Int) async {
|
|
@@ -12,6 +12,16 @@ import Foundation
|
|
|
12
12
|
extension TrackPlayerCore {
|
|
13
13
|
|
|
14
14
|
func updatePlayerQueue(tracks: [TrackItem]) {
|
|
15
|
+
// While casting, mirror the new queue to the Cast device instead of building
|
|
16
|
+
// the local AVQueuePlayer (which would start local audio).
|
|
17
|
+
if isCasting {
|
|
18
|
+
currentTracks = tracks
|
|
19
|
+
currentTrackIndex = 0
|
|
20
|
+
if let first = tracks.first { emitCastTrackChange(first) }
|
|
21
|
+
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "=", count: Constants.separatorLineLength))
|
|
16
26
|
NitroPlayerLogger.log("TrackPlayerCore", "📋 UPDATE PLAYER QUEUE - Received \(tracks.count) tracks")
|
|
17
27
|
NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength))
|
|
@@ -135,6 +145,17 @@ extension TrackPlayerCore {
|
|
|
135
145
|
NitroPlayerLogger.log("TrackPlayerCore", " Total tracks in playlist: \(self.currentTracks.count)")
|
|
136
146
|
NitroPlayerLogger.log("TrackPlayerCore", " Current index: \(self.currentTrackIndex), target index: \(index)")
|
|
137
147
|
|
|
148
|
+
// While casting, jump on the Cast device instead of rebuilding the local queue.
|
|
149
|
+
if isCasting {
|
|
150
|
+
self.playNextStack.removeAll()
|
|
151
|
+
self.upNextQueue.removeAll()
|
|
152
|
+
self.currentTemporaryType = .none
|
|
153
|
+
self.currentTrackIndex = index
|
|
154
|
+
if let track = self.getCurrentTrack() { self.emitCastTrackChange(track) }
|
|
155
|
+
self.loadCastQueue(autoplay: self.intendedToPlay, position: 0)
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
// Preserve playback state — only resume if already playing.
|
|
139
160
|
let wasPlaying = self.player?.rate ?? 0 > 0
|
|
140
161
|
|
|
@@ -389,6 +410,20 @@ extension TrackPlayerCore {
|
|
|
389
410
|
return options
|
|
390
411
|
}
|
|
391
412
|
|
|
413
|
+
/// Builds the AVURLAsset for a track URL. Cleartext-`http` remote URLs are routed through
|
|
414
|
+
/// `redirectResolver` so AVFoundation follows http→https redirects it would otherwise drop
|
|
415
|
+
/// (issue #111). HTTPS and local URLs load natively, unchanged.
|
|
416
|
+
func makeAsset(for track: TrackItem, url: URL, isLocal: Bool) -> AVURLAsset {
|
|
417
|
+
let options = assetOptions(for: track, isLocal: isLocal)
|
|
418
|
+
guard !isLocal, let wrappedURL = TrackPlayerRedirectResolver.wrap(url) else {
|
|
419
|
+
return AVURLAsset(url: url, options: options)
|
|
420
|
+
}
|
|
421
|
+
redirectResolver.registerHeaders(httpHeaders(for: track), for: wrappedURL)
|
|
422
|
+
let asset = AVURLAsset(url: wrappedURL, options: options)
|
|
423
|
+
asset.resourceLoader.setDelegate(redirectResolver, queue: redirectResolver.queue)
|
|
424
|
+
return asset
|
|
425
|
+
}
|
|
426
|
+
|
|
392
427
|
/// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
|
|
393
428
|
func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false) -> AVPlayerItem? {
|
|
394
429
|
let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
|
|
@@ -427,7 +462,7 @@ extension TrackPlayerCore {
|
|
|
427
462
|
asset = preloadedAsset
|
|
428
463
|
NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
|
|
429
464
|
} else {
|
|
430
|
-
asset =
|
|
465
|
+
asset = makeAsset(for: track, url: url, isLocal: isLocal)
|
|
431
466
|
}
|
|
432
467
|
|
|
433
468
|
let item = AVPlayerItem(asset: asset)
|
|
@@ -492,7 +527,7 @@ extension TrackPlayerCore {
|
|
|
492
527
|
url = remoteUrl
|
|
493
528
|
}
|
|
494
529
|
|
|
495
|
-
let asset =
|
|
530
|
+
let asset = self.makeAsset(for: track, url: url, isLocal: isLocal)
|
|
496
531
|
|
|
497
532
|
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
498
533
|
var allKeysLoaded = true
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//
|
|
2
|
+
// TrackPlayerRedirectResolver.swift
|
|
3
|
+
// NitroPlayer
|
|
4
|
+
//
|
|
5
|
+
// Created by Ritesh Shukla.
|
|
6
|
+
//
|
|
7
|
+
// AVURLAsset does not reliably follow HTTP→HTTPS redirects: when a cleartext `http://`
|
|
8
|
+
// track URL answers with a 30x (e.g. a universal `308 → https://...` upgrade), AVFoundation
|
|
9
|
+
// silently drops the redirect and the item fails to load (issue #111). Unlike ExoPlayer's
|
|
10
|
+
// `setAllowCrossProtocolRedirects(true)`, AVFoundation exposes no such flag.
|
|
11
|
+
//
|
|
12
|
+
// We work around it by giving such assets a sentinel URL scheme so AVFoundation has to ask
|
|
13
|
+
// this resource-loader delegate to load them. We then resolve the redirect chain ourselves
|
|
14
|
+
// with URLSession (which follows redirects) and hand AVFoundation a redirect to the final
|
|
15
|
+
// URL — after which it loads the media natively from the resolved location.
|
|
16
|
+
//
|
|
17
|
+
// Only cleartext-`http` URLs are routed here. HTTPS assets are left untouched: AVFoundation
|
|
18
|
+
// follows same-scheme redirects on its own, so only the failing http path pays the extra
|
|
19
|
+
// resolution request. The app must still permit the initial cleartext connection via ATS
|
|
20
|
+
// (`NSAppTransportSecurity`); this delegate only fixes the redirect-following, not ATS.
|
|
21
|
+
//
|
|
22
|
+
|
|
23
|
+
import AVFoundation
|
|
24
|
+
import Foundation
|
|
25
|
+
|
|
26
|
+
final class TrackPlayerRedirectResolver: NSObject, AVAssetResourceLoaderDelegate {
|
|
27
|
+
|
|
28
|
+
/// Sentinel scheme prefix. A wrapped URL looks like `nitroredirect+http://host/path`.
|
|
29
|
+
private static let schemePrefix = "nitroredirect+"
|
|
30
|
+
|
|
31
|
+
/// Serial queue the resource-loader delegate methods (and our task bookkeeping) run on.
|
|
32
|
+
let queue = DispatchQueue(label: "com.nitroplayer.redirect", qos: .userInitiated)
|
|
33
|
+
|
|
34
|
+
private let session: URLSession
|
|
35
|
+
|
|
36
|
+
// In-flight resolution tasks, keyed by loading request, so a cancelled request cancels its
|
|
37
|
+
// task. Touched only on `queue` (delegate callbacks + the task completion is hopped onto it).
|
|
38
|
+
private var tasksByRequest: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
39
|
+
|
|
40
|
+
// Custom request headers (e.g. `Authorization`) per wrapped-URL, so the resolution request can
|
|
41
|
+
// authenticate when a server requires auth even to return the redirect. Written on the player
|
|
42
|
+
// queue (registerHeaders), read on `queue`; guarded by `headersLock`.
|
|
43
|
+
private var headersByURL: [String: [String: String]] = [:]
|
|
44
|
+
private let headersLock = NSLock()
|
|
45
|
+
|
|
46
|
+
override init() {
|
|
47
|
+
let config = URLSessionConfiguration.ephemeral
|
|
48
|
+
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
49
|
+
session = URLSession(configuration: config)
|
|
50
|
+
super.init()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// MARK: - URL wrapping
|
|
54
|
+
|
|
55
|
+
/// Wraps a cleartext-`http` URL in the sentinel scheme so AVFoundation routes it here.
|
|
56
|
+
/// Returns nil for anything else (https, file, custom schemes) — those load natively.
|
|
57
|
+
static func wrap(_ url: URL) -> URL? {
|
|
58
|
+
guard url.scheme?.lowercased() == "http",
|
|
59
|
+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
60
|
+
else { return nil }
|
|
61
|
+
components.scheme = schemePrefix + "http"
|
|
62
|
+
return components.url
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Restores the real URL from a sentinel-wrapped URL.
|
|
66
|
+
private static func unwrap(_ url: URL) -> URL? {
|
|
67
|
+
guard let scheme = url.scheme?.lowercased(), scheme.hasPrefix(schemePrefix),
|
|
68
|
+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
69
|
+
else { return nil }
|
|
70
|
+
components.scheme = String(scheme.dropFirst(schemePrefix.count))
|
|
71
|
+
return components.url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// MARK: - Header registry
|
|
75
|
+
|
|
76
|
+
func registerHeaders(_ headers: [String: String]?, for wrappedURL: URL) {
|
|
77
|
+
guard let headers, !headers.isEmpty else { return }
|
|
78
|
+
headersLock.lock()
|
|
79
|
+
defer { headersLock.unlock() }
|
|
80
|
+
headersByURL[wrappedURL.absoluteString] = headers
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Releases all registered headers. Call when the player is torn down.
|
|
84
|
+
func clear() {
|
|
85
|
+
headersLock.lock()
|
|
86
|
+
defer { headersLock.unlock() }
|
|
87
|
+
headersByURL.removeAll()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func headers(for wrappedURL: URL) -> [String: String]? {
|
|
91
|
+
headersLock.lock()
|
|
92
|
+
defer { headersLock.unlock() }
|
|
93
|
+
return headersByURL[wrappedURL.absoluteString]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - AVAssetResourceLoaderDelegate
|
|
97
|
+
|
|
98
|
+
func resourceLoader(
|
|
99
|
+
_ resourceLoader: AVAssetResourceLoader,
|
|
100
|
+
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
|
101
|
+
) -> Bool {
|
|
102
|
+
guard let wrappedURL = loadingRequest.request.url,
|
|
103
|
+
let realURL = Self.unwrap(wrappedURL)
|
|
104
|
+
else { return false }
|
|
105
|
+
|
|
106
|
+
var request = URLRequest(url: realURL)
|
|
107
|
+
// HEAD is enough: we only need `response.url` after URLSession follows the redirect chain.
|
|
108
|
+
// Even if the resolved endpoint rejects HEAD (405), the post-redirect URL is still reported.
|
|
109
|
+
request.httpMethod = "HEAD"
|
|
110
|
+
headers(for: wrappedURL)?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
|
111
|
+
loadingRequest.request.allHTTPHeaderFields?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
|
|
112
|
+
|
|
113
|
+
let key = ObjectIdentifier(loadingRequest)
|
|
114
|
+
let task = session.dataTask(with: request) { [weak self] _, response, error in
|
|
115
|
+
self?.queue.async {
|
|
116
|
+
guard let self else { return }
|
|
117
|
+
self.tasksByRequest.removeValue(forKey: key)
|
|
118
|
+
guard !loadingRequest.isCancelled, !loadingRequest.isFinished else { return }
|
|
119
|
+
|
|
120
|
+
if let error {
|
|
121
|
+
NitroPlayerLogger.log("TrackPlayerCore", "❌ Redirect resolution failed for \(realURL): \(error.localizedDescription)")
|
|
122
|
+
loadingRequest.finishLoading(with: error)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `response.url` is the final URL after URLSession followed all redirects.
|
|
127
|
+
let finalURL = response?.url ?? realURL
|
|
128
|
+
if finalURL != realURL {
|
|
129
|
+
NitroPlayerLogger.log("TrackPlayerCore", "↪️ Resolved redirect \(realURL) → \(finalURL)")
|
|
130
|
+
}
|
|
131
|
+
var redirect = URLRequest(url: finalURL)
|
|
132
|
+
self.headers(for: wrappedURL)?.forEach { redirect.setValue($1, forHTTPHeaderField: $0) }
|
|
133
|
+
loadingRequest.redirect = redirect
|
|
134
|
+
loadingRequest.response = HTTPURLResponse(
|
|
135
|
+
url: finalURL, statusCode: 302, httpVersion: "HTTP/1.1", headerFields: nil)
|
|
136
|
+
loadingRequest.finishLoading()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
tasksByRequest[key] = task
|
|
140
|
+
task.resume()
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func resourceLoader(
|
|
145
|
+
_ resourceLoader: AVAssetResourceLoader,
|
|
146
|
+
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
|
147
|
+
) {
|
|
148
|
+
tasksByRequest.removeValue(forKey: ObjectIdentifier(loadingRequest))?.cancel()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { CastState } from '../specs/Cast.nitro';
|
|
4
|
+
export interface CastButtonProps extends Omit<PressableProps, 'children'> {
|
|
5
|
+
/** Icon size in points. Defaults to `24`. */
|
|
6
|
+
size?: number;
|
|
7
|
+
/** Glyph color when not connected. Defaults to `#000000`. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Glyph color while connecting / connected. Defaults to `#1E88E5`. */
|
|
10
|
+
activeColor?: string;
|
|
11
|
+
/** Style applied to the pressable container. */
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
/**
|
|
14
|
+
* Hide the button entirely when no Cast devices are available on the network,
|
|
15
|
+
* matching standard platform Cast-button behaviour. Defaults to `true`.
|
|
16
|
+
*/
|
|
17
|
+
hideWhenNoDevices?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Render a custom icon instead of the built-in Cast glyph. Receives the live
|
|
20
|
+
* connection state so you can theme it yourself.
|
|
21
|
+
*/
|
|
22
|
+
renderIcon?: (info: {
|
|
23
|
+
state: CastState;
|
|
24
|
+
isCasting: boolean;
|
|
25
|
+
size: number;
|
|
26
|
+
color: string;
|
|
27
|
+
}) => React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A ready-to-use Google Cast button.
|
|
31
|
+
*
|
|
32
|
+
* Reflects the live connection state, hides itself when no devices are available
|
|
33
|
+
* (configurable), and presents the native Cast dialog on press. For full control
|
|
34
|
+
* over the icon, pass `renderIcon`; for custom press handling, pass `onPress`
|
|
35
|
+
* (the default opens the system Cast picker).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* import { CastButton } from 'react-native-nitro-player'
|
|
40
|
+
*
|
|
41
|
+
* <CastButton size={28} activeColor="#1DB954" />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function CastButton({ size, color, activeColor, hideWhenNoDevices, renderIcon, onPress, style, ...rest }: CastButtonProps): React.ReactElement | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, View, } from 'react-native';
|
|
3
|
+
import { Cast } from '../index';
|
|
4
|
+
import { useCastState } from '../hooks/useCastState';
|
|
5
|
+
/**
|
|
6
|
+
* A ready-to-use Google Cast button.
|
|
7
|
+
*
|
|
8
|
+
* Reflects the live connection state, hides itself when no devices are available
|
|
9
|
+
* (configurable), and presents the native Cast dialog on press. For full control
|
|
10
|
+
* over the icon, pass `renderIcon`; for custom press handling, pass `onPress`
|
|
11
|
+
* (the default opens the system Cast picker).
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { CastButton } from 'react-native-nitro-player'
|
|
16
|
+
*
|
|
17
|
+
* <CastButton size={28} activeColor="#1DB954" />
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function CastButton({ size = 24, color = '#000000', activeColor = '#1E88E5', hideWhenNoDevices = true, renderIcon, onPress, style, ...rest }) {
|
|
21
|
+
const { state, isCasting } = useCastState();
|
|
22
|
+
if (hideWhenNoDevices && state === 'no_devices_available') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const active = state === 'connected' || state === 'connecting';
|
|
26
|
+
const glyphColor = active ? activeColor : color;
|
|
27
|
+
const handlePress = (event) => {
|
|
28
|
+
if (onPress) {
|
|
29
|
+
onPress(event);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
Cast.showCastPicker();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error('[CastButton] Failed to present cast picker:', error);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel: isCasting ? 'Casting' : 'Cast', accessibilityState: { selected: isCasting }, hitSlop: 8, onPress: handlePress, style: style, ...rest }, renderIcon ? (renderIcon({ state, isCasting, size, color: glyphColor })) : (React.createElement(CastGlyph, { size: size, color: glyphColor }))));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Dependency-free rendition of the standard Cast glyph: a rounded "screen"
|
|
43
|
+
* rectangle with a broadcast signal (dot + two arcs) in the bottom-left corner.
|
|
44
|
+
* Drawn purely with `View`s so the library stays dependency-free.
|
|
45
|
+
*/
|
|
46
|
+
function CastGlyph({ size, color, }) {
|
|
47
|
+
const stroke = Math.max(1.5, Math.round(size * 0.083));
|
|
48
|
+
const inset = stroke;
|
|
49
|
+
const dotSize = stroke * 1.7;
|
|
50
|
+
const r1 = size * 0.28; // inner arc radius
|
|
51
|
+
const r2 = size * 0.46; // outer arc radius
|
|
52
|
+
return (React.createElement(View, { style: { width: size, height: size } },
|
|
53
|
+
React.createElement(View, { style: [
|
|
54
|
+
StyleSheet.absoluteFill,
|
|
55
|
+
{
|
|
56
|
+
borderWidth: stroke,
|
|
57
|
+
borderColor: color,
|
|
58
|
+
borderRadius: Math.max(2, size * 0.12),
|
|
59
|
+
},
|
|
60
|
+
] }),
|
|
61
|
+
React.createElement(View, { style: {
|
|
62
|
+
position: 'absolute',
|
|
63
|
+
left: inset,
|
|
64
|
+
bottom: inset,
|
|
65
|
+
width: dotSize,
|
|
66
|
+
height: dotSize,
|
|
67
|
+
borderRadius: dotSize / 2,
|
|
68
|
+
backgroundColor: color,
|
|
69
|
+
} }),
|
|
70
|
+
React.createElement(Arc, { origin: inset + dotSize / 2, radius: r1, stroke: stroke, color: color }),
|
|
71
|
+
React.createElement(Arc, { origin: inset + dotSize / 2, radius: r2, stroke: stroke, color: color })));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A quarter-circle arc (the top-right quadrant) centered at the bottom-left
|
|
75
|
+
* `origin` point. Produced by clipping a full bordered circle to one quadrant.
|
|
76
|
+
*/
|
|
77
|
+
function Arc({ origin, radius, stroke, color, }) {
|
|
78
|
+
return (React.createElement(View
|
|
79
|
+
// Clip box reveals only the quadrant facing up-and-right from the origin.
|
|
80
|
+
, {
|
|
81
|
+
// Clip box reveals only the quadrant facing up-and-right from the origin.
|
|
82
|
+
style: {
|
|
83
|
+
position: 'absolute',
|
|
84
|
+
left: origin,
|
|
85
|
+
bottom: origin - radius,
|
|
86
|
+
width: radius,
|
|
87
|
+
height: radius,
|
|
88
|
+
overflow: 'hidden',
|
|
89
|
+
} },
|
|
90
|
+
React.createElement(View, { style: {
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
left: -radius,
|
|
93
|
+
top: 0,
|
|
94
|
+
width: radius * 2,
|
|
95
|
+
height: radius * 2,
|
|
96
|
+
borderRadius: radius,
|
|
97
|
+
borderWidth: stroke,
|
|
98
|
+
borderColor: color,
|
|
99
|
+
} })));
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CastState } from '../specs/Cast.nitro';
|
|
2
|
+
type CastStateCallback = (state: CastState, deviceName: string | null) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Internal subscription manager that allows multiple hooks/components to
|
|
5
|
+
* subscribe to the single native `Cast.onCastStateChange` callback without
|
|
6
|
+
* overwriting each other. Mirrors {@link callbackManager}.
|
|
7
|
+
*/
|
|
8
|
+
declare class CastCallbackSubscriptionManager {
|
|
9
|
+
private castStateSubscribers;
|
|
10
|
+
private isCastStateRegistered;
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to Cast connection state changes.
|
|
13
|
+
* @returns Unsubscribe function
|
|
14
|
+
*/
|
|
15
|
+
subscribeToCastState(callback: CastStateCallback): () => void;
|
|
16
|
+
private ensureCastStateRegistered;
|
|
17
|
+
}
|
|
18
|
+
export declare const castCallbackManager: CastCallbackSubscriptionManager;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Cast } from '../index';
|
|
2
|
+
/**
|
|
3
|
+
* Internal subscription manager that allows multiple hooks/components to
|
|
4
|
+
* subscribe to the single native `Cast.onCastStateChange` callback without
|
|
5
|
+
* overwriting each other. Mirrors {@link callbackManager}.
|
|
6
|
+
*/
|
|
7
|
+
class CastCallbackSubscriptionManager {
|
|
8
|
+
castStateSubscribers = new Set();
|
|
9
|
+
isCastStateRegistered = false;
|
|
10
|
+
/**
|
|
11
|
+
* Subscribe to Cast connection state changes.
|
|
12
|
+
* @returns Unsubscribe function
|
|
13
|
+
*/
|
|
14
|
+
subscribeToCastState(callback) {
|
|
15
|
+
this.castStateSubscribers.add(callback);
|
|
16
|
+
this.ensureCastStateRegistered();
|
|
17
|
+
return () => {
|
|
18
|
+
this.castStateSubscribers.delete(callback);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
ensureCastStateRegistered() {
|
|
22
|
+
if (this.isCastStateRegistered)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
Cast.onCastStateChange((state, deviceName) => {
|
|
26
|
+
this.castStateSubscribers.forEach((subscriber) => {
|
|
27
|
+
try {
|
|
28
|
+
subscriber(state, deviceName);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error('[CastCallbackManager] Error in cast state subscriber:', error);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
this.isCastStateRegistered = true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error('[CastCallbackManager] Failed to register cast state callback:', error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Export singleton instance
|
|
43
|
+
export const castCallbackManager = new CastCallbackSubscriptionManager();
|
package/lib/hooks/index.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export { usePlaylist } from './usePlaylist';
|
|
|
11
11
|
export type { UsePlaylistResult } from './usePlaylist';
|
|
12
12
|
export { useActualQueue } from './useActualQueue';
|
|
13
13
|
export type { UseActualQueueResult } from './useActualQueue';
|
|
14
|
+
export { useIsCasting } from './useIsCasting';
|
|
15
|
+
export { useCastState } from './useCastState';
|
|
16
|
+
export type { UseCastStateResult } from './useCastState';
|
|
14
17
|
export { useDownloadProgress } from './useDownloadProgress';
|
|
15
18
|
export type { UseDownloadProgressOptions, UseDownloadProgressResult, } from './useDownloadProgress';
|
|
16
19
|
export { useDownloadedTracks } from './useDownloadedTracks';
|
package/lib/hooks/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export { useAudioDevices } from './useAudioDevices';
|
|
|
7
7
|
export { useNowPlaying } from './useNowPlaying';
|
|
8
8
|
export { usePlaylist } from './usePlaylist';
|
|
9
9
|
export { useActualQueue } from './useActualQueue';
|
|
10
|
+
// Cast hooks
|
|
11
|
+
export { useIsCasting } from './useIsCasting';
|
|
12
|
+
export { useCastState } from './useCastState';
|
|
10
13
|
// Download hooks
|
|
11
14
|
export { useDownloadProgress } from './useDownloadProgress';
|
|
12
15
|
export { useDownloadedTracks } from './useDownloadedTracks';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CastState } from '../specs/Cast.nitro';
|
|
2
|
+
export interface UseCastStateResult {
|
|
3
|
+
/** The current Cast connection state. */
|
|
4
|
+
state: CastState;
|
|
5
|
+
/** Friendly name of the connected device, or `null` when not connected. */
|
|
6
|
+
deviceName: string | null;
|
|
7
|
+
/** Convenience flag, equivalent to `state === 'connected'`. */
|
|
8
|
+
isCasting: boolean;
|
|
9
|
+
/** Whether the initial state has been read from native. */
|
|
10
|
+
isReady: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Hook to subscribe to the full Google Cast connection state.
|
|
14
|
+
*
|
|
15
|
+
* Provides the connection {@link CastState}, the connected device name, and a
|
|
16
|
+
* convenience `isCasting` flag. For a simple boolean prefer {@link useIsCasting}.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* function CastStatus() {
|
|
21
|
+
* const { state, deviceName, isCasting } = useCastState()
|
|
22
|
+
* if (!isCasting) return null
|
|
23
|
+
* return <Text>Casting to {deviceName} ({state})</Text>
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function useCastState(): UseCastStateResult;
|