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.
Files changed (68) hide show
  1. package/NitroPlayer.podspec +6 -0
  2. package/README.md +113 -0
  3. package/android/build.gradle +8 -0
  4. package/android/src/main/AndroidManifest.xml +11 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridCast.kt +79 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +10 -7
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +122 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +13 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +7 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +160 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastConfig.kt +23 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroCastOptionsProvider.kt +30 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroMediaItemConverter.kt +53 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +33 -0
  15. package/ios/HybridCast.swift +63 -0
  16. package/ios/core/CastSessionManager.swift +367 -0
  17. package/ios/core/TrackPlayerCast.swift +109 -0
  18. package/ios/core/TrackPlayerCore.swift +28 -2
  19. package/ios/core/TrackPlayerPlayback.swift +32 -0
  20. package/ios/core/TrackPlayerQueue.swift +8 -1
  21. package/ios/core/TrackPlayerQueueBuild.swift +37 -2
  22. package/ios/core/TrackPlayerRedirectResolver.swift +150 -0
  23. package/lib/components/CastButton.d.ts +44 -0
  24. package/lib/components/CastButton.js +100 -0
  25. package/lib/hooks/castCallbackManager.d.ts +19 -0
  26. package/lib/hooks/castCallbackManager.js +43 -0
  27. package/lib/hooks/index.d.ts +3 -0
  28. package/lib/hooks/index.js +3 -0
  29. package/lib/hooks/useCastState.d.ts +27 -0
  30. package/lib/hooks/useCastState.js +49 -0
  31. package/lib/hooks/useIsCasting.d.ts +17 -0
  32. package/lib/hooks/useIsCasting.js +43 -0
  33. package/lib/index.d.ts +5 -0
  34. package/lib/index.js +4 -1
  35. package/lib/specs/Cast.nitro.d.ts +58 -0
  36. package/lib/specs/Cast.nitro.js +1 -0
  37. package/nitro.json +10 -0
  38. package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +3 -1
  39. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +18 -0
  40. package/nitrogen/generated/android/c++/JCastState.hpp +64 -0
  41. package/nitrogen/generated/android/c++/JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp +83 -0
  42. package/nitrogen/generated/android/c++/JHybridCastSpec.cpp +115 -0
  43. package/nitrogen/generated/android/c++/JHybridCastSpec.hpp +69 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/CastState.kt +25 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.kt +80 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridCastSpec.kt +85 -0
  47. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +38 -13
  48. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +176 -125
  49. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +8 -0
  50. package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +8 -0
  51. package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +12 -0
  52. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.cpp +11 -0
  53. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.hpp +133 -0
  54. package/nitrogen/generated/ios/swift/CastState.swift +48 -0
  55. package/nitrogen/generated/ios/swift/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.swift +65 -0
  56. package/nitrogen/generated/ios/swift/HybridCastSpec.swift +61 -0
  57. package/nitrogen/generated/ios/swift/HybridCastSpec_cxx.swift +254 -0
  58. package/nitrogen/generated/shared/c++/CastState.hpp +84 -0
  59. package/nitrogen/generated/shared/c++/HybridCastSpec.cpp +27 -0
  60. package/nitrogen/generated/shared/c++/HybridCastSpec.hpp +75 -0
  61. package/package.json +3 -3
  62. package/src/components/CastButton.tsx +196 -0
  63. package/src/hooks/castCallbackManager.ts +55 -0
  64. package/src/hooks/index.ts +5 -0
  65. package/src/hooks/useCastState.ts +67 -0
  66. package/src/hooks/useIsCasting.ts +47 -0
  67. package/src/index.ts +9 -0
  68. 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?.mediaSessionManager = MediaSessionManager()
116
- self?.mediaSessionManager?.setTrackPlayerCore(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
- await withPlayerQueueNoThrow { self.skipToIndexInternal(index: index) }
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 = AVURLAsset(url: url, options: assetOptions(for: track, isLocal: isLocal))
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 = AVURLAsset(url: url, options: self.assetOptions(for: track, isLocal: isLocal))
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();
@@ -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';
@@ -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;