react-native-nitro-player 1.4.4-alpha.0 → 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 (67) 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 +23 -2
  19. package/ios/core/TrackPlayerPlayback.swift +32 -0
  20. package/ios/core/TrackPlayerQueue.swift +8 -1
  21. package/ios/core/TrackPlayerQueueBuild.swift +21 -0
  22. package/lib/components/CastButton.d.ts +44 -0
  23. package/lib/components/CastButton.js +100 -0
  24. package/lib/hooks/castCallbackManager.d.ts +19 -0
  25. package/lib/hooks/castCallbackManager.js +43 -0
  26. package/lib/hooks/index.d.ts +3 -0
  27. package/lib/hooks/index.js +3 -0
  28. package/lib/hooks/useCastState.d.ts +27 -0
  29. package/lib/hooks/useCastState.js +49 -0
  30. package/lib/hooks/useIsCasting.d.ts +17 -0
  31. package/lib/hooks/useIsCasting.js +43 -0
  32. package/lib/index.d.ts +5 -0
  33. package/lib/index.js +4 -1
  34. package/lib/specs/Cast.nitro.d.ts +58 -0
  35. package/lib/specs/Cast.nitro.js +1 -0
  36. package/nitro.json +10 -0
  37. package/nitrogen/generated/android/NitroPlayer+autolinking.cmake +3 -1
  38. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +18 -0
  39. package/nitrogen/generated/android/c++/JCastState.hpp +64 -0
  40. package/nitrogen/generated/android/c++/JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp +83 -0
  41. package/nitrogen/generated/android/c++/JHybridCastSpec.cpp +115 -0
  42. package/nitrogen/generated/android/c++/JHybridCastSpec.hpp +69 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/CastState.kt +25 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridCastSpec.kt +85 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +38 -13
  47. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +176 -125
  48. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +8 -0
  49. package/nitrogen/generated/ios/NitroPlayerAutolinking.mm +8 -0
  50. package/nitrogen/generated/ios/NitroPlayerAutolinking.swift +12 -0
  51. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.cpp +11 -0
  52. package/nitrogen/generated/ios/c++/HybridCastSpecSwift.hpp +133 -0
  53. package/nitrogen/generated/ios/swift/CastState.swift +48 -0
  54. package/nitrogen/generated/ios/swift/Func_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.swift +65 -0
  55. package/nitrogen/generated/ios/swift/HybridCastSpec.swift +61 -0
  56. package/nitrogen/generated/ios/swift/HybridCastSpec_cxx.swift +254 -0
  57. package/nitrogen/generated/shared/c++/CastState.hpp +84 -0
  58. package/nitrogen/generated/shared/c++/HybridCastSpec.cpp +27 -0
  59. package/nitrogen/generated/shared/c++/HybridCastSpec.hpp +75 -0
  60. package/package.json +3 -3
  61. package/src/components/CastButton.tsx +196 -0
  62. package/src/hooks/castCallbackManager.ts +55 -0
  63. package/src/hooks/index.ts +5 -0
  64. package/src/hooks/useCastState.ts +67 -0
  65. package/src/hooks/useIsCasting.ts +47 -0
  66. package/src/index.ts +9 -0
  67. package/src/specs/Cast.nitro.ts +72 -0
@@ -27,5 +27,11 @@ Pod::Spec.new do |s|
27
27
 
28
28
  s.dependency 'React-jsi'
29
29
  s.dependency 'React-callinvoker'
30
+
31
+ # Google Cast (CAF) — provides GCKCastContext / GCKRemoteMediaClient used by the
32
+ # Cast HybridObject. Requires NSBonjourServices + NSLocalNetworkUsageDescription
33
+ # (and, for guest mode, NSBluetoothAlwaysUsageDescription) in the host app's Info.plist.
34
+ s.dependency 'google-cast-sdk', '~> 4.8'
35
+
30
36
  install_modules_dependencies(s)
31
37
  end
package/README.md CHANGED
@@ -90,6 +90,8 @@ Mutations return **`Promise<void>`** (or **`Promise<string>`** for `createPlayli
90
90
  | `AudioDevices.getAudioDevices()` | Android | Returns list of available audio devices. |
91
91
  | `AudioDevices.setAudioDevice(id)` | Android | Sets the active audio output device. |
92
92
  | `AudioRoutePicker.showRoutePicker()` | iOS | Opens the native AirPlay/Audio Route picker menu. |
93
+ | `Cast.showCastPicker()` | iOS & Android | Opens the native Google Cast device picker. |
94
+ | `Cast.isCasting()` / `useIsCasting()` | iOS & Android | Whether playback is routed to a Cast device. |
93
95
  | `AndroidAutoMediaLibraryHelper.set(...)` | Android | Sets custom folder structure for Android Auto. |
94
96
  | `AndroidAutoMediaLibraryHelper.clear()` | Android | Resets Android Auto structure to default. |
95
97
 
@@ -598,6 +600,116 @@ if (AudioRoutePicker) {
598
600
  ```
599
601
 
600
602
 
603
+ ## Google Cast
604
+
605
+ Cast playback to Chromecast / Google TV devices. Casting is **seamless**: it is
606
+ implemented as an internal playback-backend swap, so a Cast device behaves like
607
+ another output for the existing player. When a session is connected, audio plays
608
+ **only on the Cast device**, the local output is silenced automatically, and your
609
+ existing queue, transport controls (in-app, lock screen, notification) and events
610
+ keep working against the remote. On disconnect, local playback resumes at the same
611
+ position.
612
+
613
+ - **Android** uses Media3's `CastPlayer` (the local ExoPlayer is swapped to it).
614
+ - **iOS** uses the Google Cast SDK (`GCKRemoteMediaClient`).
615
+
616
+ ### JS API
617
+
618
+ ```ts
619
+ import { Cast, CastButton, useIsCasting, useCastState } from 'react-native-nitro-player'
620
+
621
+ // Optional: call once early (e.g. on app start) to use your own registered
622
+ // Web Receiver. Omit / pass nothing to use the Default Media Receiver.
623
+ await Cast.configure('YOUR_RECEIVER_APP_ID')
624
+
625
+ Cast.isCasting() // boolean — is playback currently on a Cast device
626
+ Cast.getCastState() // 'no_devices_available' | 'not_connected' | 'connecting' | 'connected'
627
+ Cast.getCastDeviceName() // string | null
628
+ Cast.showCastPicker() // present the native device chooser / disconnect dialog
629
+ await Cast.endCastSession() // stop casting, resume locally
630
+
631
+ // Low-level event listener (the hooks below wrap this)
632
+ Cast.onCastStateChange((state, deviceName) => { /* ... */ })
633
+ ```
634
+
635
+ > ℹ️ The receiver application ID is fixed for the process lifetime once Cast
636
+ > initializes. Call `Cast.configure(id)` as early as possible; otherwise the change
637
+ > applies on the next app launch. The **Default Media Receiver** works out of the box.
638
+
639
+ ### Hooks
640
+
641
+ ```tsx
642
+ function NowPlaying() {
643
+ const isCasting = useIsCasting() // boolean
644
+
645
+ // Or the richer variant:
646
+ const { state, deviceName, isCasting, isReady } = useCastState()
647
+
648
+ return <Text>{isCasting ? `Casting to ${deviceName}` : 'Playing locally'}</Text>
649
+ }
650
+ ```
651
+
652
+ ### `<CastButton />`
653
+
654
+ A ready-to-use button that reflects the live connection state, hides itself when
655
+ no devices are available, and opens the native Cast dialog on press.
656
+
657
+ ```tsx
658
+ import { CastButton } from 'react-native-nitro-player'
659
+
660
+ <CastButton size={28} color="#888" activeColor="#1DB954" />
661
+ ```
662
+
663
+ Props: `size`, `color`, `activeColor`, `hideWhenNoDevices` (default `true`),
664
+ `style`, `onPress` (override the default picker), and `renderIcon` (supply your own
665
+ icon — receives `{ state, isCasting, size, color }`). Any other `Pressable` prop is
666
+ forwarded. Prefer building your own button? Call `Cast.showCastPicker()` directly.
667
+
668
+ ### Setup
669
+
670
+ Cast requires native configuration in your **app** (not just the library).
671
+
672
+ **iOS** — add to `ios/<App>/Info.plist`:
673
+
674
+ ```xml
675
+ <key>NSLocalNetworkUsageDescription</key>
676
+ <string>${PRODUCT_NAME} uses the local network to discover Cast devices.</string>
677
+ <key>NSBonjourServices</key>
678
+ <array>
679
+ <string>_googlecast._tcp</string>
680
+ <!-- If using a custom receiver, also add _<RECEIVER_APP_ID>._googlecast._tcp -->
681
+ </array>
682
+ <!-- Only if you use Cast guest mode (full SDK): -->
683
+ <key>NSBluetoothAlwaysUsageDescription</key>
684
+ <string>${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices.</string>
685
+ ```
686
+
687
+ The Google Cast SDK is pulled in automatically via the pod dependency — just run
688
+ `pod install`. No `AppDelegate` code is required; the library initializes the Cast
689
+ context on launch (with the Default Media Receiver, or your `Cast.configure` ID).
690
+
691
+ **Android** — no app code required. The library declares the Cast `OptionsProvider`
692
+ and discovery permissions in its manifest (merged automatically) and initializes the
693
+ Cast framework from its playback service at launch. Cast device discovery requires
694
+ Google Play services on the device (unavailable on most emulators).
695
+
696
+ > Apps that also use another Cast `OptionsProvider` (e.g. `react-native-google-cast`)
697
+ > must override the merged meta-data with `tools:replace`. To set a custom receiver
698
+ > ID without `Cast.configure`, override the
699
+ > `com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME` meta-data with
700
+ > your own provider.
701
+
702
+ ### Notes & limitations
703
+
704
+ - **Testing requires a real Cast device** on the same Wi-Fi network — there is no
705
+ simulator/emulator support.
706
+ - The full effective queue (playlist + play-next + up-next) is mirrored to the
707
+ device, so skip / previous / seek work remotely.
708
+ - On iOS, while casting the lock-screen Now Playing metadata is best-effort; the
709
+ transport buttons themselves control the Cast device.
710
+ - If a device has no Google Play services (Android) or the SDK is otherwise
711
+ unavailable, Cast features degrade to inert no-ops (`isCasting` stays `false`).
712
+
601
713
  ## Equalizer
602
714
 
603
715
  The player includes a powerful 5-band equalizer that works on both iOS and Android.
@@ -1121,6 +1233,7 @@ AndroidAutoMediaLibraryHelper.set({
1121
1233
  - ✅ **Notification Controls**: Show playback controls in notifications
1122
1234
  - ✅ **Progress Tracking**: Real-time playback progress updates
1123
1235
  - ✅ **Offline Downloads**: Download tracks and playlists for offline playback
1236
+ - ✅ **Google Cast**: Seamless casting to Chromecast / Google TV (iOS & Android)
1124
1237
 
1125
1238
  ## TypeScript Support
1126
1239
 
@@ -45,6 +45,8 @@ android {
45
45
  minSdkVersion getExtOrIntegerDefault("minSdkVersion")
46
46
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
47
47
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
48
+ // Keep the Cast OptionsProvider (referenced reflectively from the manifest) under R8.
49
+ consumerProguardFiles "consumer-rules.pro"
48
50
 
49
51
  externalNativeBuild {
50
52
  cmake {
@@ -147,6 +149,12 @@ dependencies {
147
149
  implementation "androidx.media3:media3-common:$media3_version"
148
150
  implementation "androidx.media:media:1.7.0"
149
151
 
152
+ // Google Cast: CastPlayer (drop-in Media3 Player), the Cast framework and the
153
+ // MediaRouter chooser/controller dialogs used by Cast.showCastPicker().
154
+ implementation "androidx.media3:media3-cast:$media3_version"
155
+ implementation "com.google.android.gms:play-services-cast-framework:21.5.0"
156
+ implementation "androidx.mediarouter:mediarouter:1.7.0"
157
+
150
158
  // WorkManager for background downloads.
151
159
  implementation "androidx.work:work-runtime-ktx:2.10.5" // Latest version of 2.10
152
160
  }
@@ -8,12 +8,23 @@
8
8
  <uses-permission android:name="android.permission.WAKE_LOCK" />
9
9
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
10
10
 
11
+ <!-- Google Cast device discovery (mDNS / Cast SDK) -->
12
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
13
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
14
+ <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
15
+
11
16
  <!-- Required so Android Auto / Assistant treat this as a media app. -->
12
17
  <uses-feature
13
18
  android:name="android.hardware.audio.output"
14
19
  android:required="false" />
15
20
 
16
21
  <application>
22
+ <!-- Google Cast: supplies CastOptions (receiver app ID) to the Cast SDK.
23
+ Apps shipping their own OptionsProvider must override with tools:replace. -->
24
+ <meta-data
25
+ android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
26
+ android:value="com.margelo.nitro.nitroplayer.media.NitroCastOptionsProvider" />
27
+
17
28
  <!-- PlaybackService keeps music alive when screen is locked / app backgrounded -->
18
29
  <service
19
30
  android:name="com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService"
@@ -0,0 +1,79 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer
4
+
5
+ import androidx.annotation.Keep
6
+ import com.facebook.proguard.annotations.DoNotStrip
7
+ import com.margelo.nitro.NitroModules
8
+ import com.margelo.nitro.core.NullType
9
+ import com.margelo.nitro.core.Promise
10
+ import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
11
+ import com.margelo.nitro.nitroplayer.core.configureCast
12
+ import com.margelo.nitro.nitroplayer.core.endCastSession
13
+ import com.margelo.nitro.nitroplayer.core.getCastDeviceName
14
+ import com.margelo.nitro.nitroplayer.core.getCastState
15
+ import com.margelo.nitro.nitroplayer.core.isCasting
16
+ import com.margelo.nitro.nitroplayer.core.showCastPicker
17
+
18
+ /**
19
+ * Nitro module exposing the Google Cast control surface to JS. Routing of actual
20
+ * playback to/from the Cast device is handled internally by [TrackPlayerCore]
21
+ * (see `TrackPlayerCast`); this object only forwards Cast-specific calls.
22
+ */
23
+ @DoNotStrip
24
+ @Keep
25
+ class HybridCast : HybridCastSpec() {
26
+ private val applicationContext = NitroModules.applicationContext
27
+ private val core: TrackPlayerCore
28
+
29
+ private val listenerIds = mutableListOf<Long>()
30
+
31
+ init {
32
+ val context =
33
+ applicationContext
34
+ ?: throw IllegalStateException("React Context is not initialized")
35
+ core = TrackPlayerCore.getInstance(context)
36
+ }
37
+
38
+ override fun configure(receiverApplicationId: String?): Promise<Unit> =
39
+ Promise.async { core.configureCast(receiverApplicationId) }
40
+
41
+ override fun isCasting(): Boolean = core.isCasting()
42
+
43
+ override fun getCastState(): CastState = core.getCastState()
44
+
45
+ override fun getCastDeviceName(): Variant_NullType_String {
46
+ val name = core.getCastDeviceName()
47
+ return if (name != null) {
48
+ Variant_NullType_String.create(name)
49
+ } else {
50
+ Variant_NullType_String.create(NullType.NULL)
51
+ }
52
+ }
53
+
54
+ override fun showCastPicker() {
55
+ val activity = applicationContext?.currentActivity ?: return
56
+ activity.runOnUiThread { core.showCastPicker(activity) }
57
+ }
58
+
59
+ override fun endCastSession(): Promise<Unit> = Promise.async { core.endCastSession() }
60
+
61
+ override fun onCastStateChange(callback: (state: CastState, deviceName: Variant_NullType_String?) -> Unit) {
62
+ // Replace any prior listener registered through this HybridObject so JS
63
+ // reloads (Fast Refresh) don't accumulate handlers.
64
+ listenerIds.forEach { core.removeOnCastStateChangeListener(it) }
65
+ listenerIds.clear()
66
+
67
+ val id =
68
+ core.addOnCastStateChangeListener { state, deviceName ->
69
+ callback(state, deviceName?.let { Variant_NullType_String.create(it) })
70
+ }
71
+ listenerIds += id
72
+ }
73
+
74
+ override fun dispose() {
75
+ super.dispose()
76
+ listenerIds.forEach { core.removeOnCastStateChangeListener(it) }
77
+ listenerIds.clear()
78
+ }
79
+ }
@@ -2,18 +2,21 @@ package com.margelo.nitro.nitroplayer.core
2
2
 
3
3
  import androidx.media3.common.MediaItem
4
4
  import androidx.media3.common.Player
5
- import androidx.media3.exoplayer.ExoPlayer
6
5
 
7
6
  /**
8
- * Thin wrapper around an [ExoPlayer] instance owned by the playback service.
9
- * All delegation methods are unchanged — only the constructor now accepts an
10
- * existing player instead of building one.
7
+ * Thin wrapper around the **currently active** Media3 [Player].
8
+ *
9
+ * This is normally the service-owned `ExoPlayer` (local playback) but can be
10
+ * swapped to a `CastPlayer` while a Google Cast session is active — both
11
+ * implement the same [Player] interface, so every delegation method below works
12
+ * unchanged regardless of which backend is playing. The wrapper instance is
13
+ * reassigned (see `TrackPlayerCast.switchToPlayer`) when the backend changes.
11
14
  */
12
15
  class ExoPlayerCore(
13
- exoPlayer: ExoPlayer,
16
+ player: Player,
14
17
  ) {
15
- /** The underlying ExoPlayer instance — accessible for wiring. */
16
- internal val player: ExoPlayer = exoPlayer
18
+ /** The underlying active player instance — accessible for wiring. */
19
+ internal val player: Player = player
17
20
 
18
21
  // ── Playback ───────────────────────────────────────────────────────────
19
22
  fun play() = player.play()
@@ -0,0 +1,122 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.core
4
+
5
+ import android.app.Activity
6
+ import androidx.media3.common.MediaItem
7
+ import androidx.media3.common.Player
8
+ import com.margelo.nitro.nitroplayer.CastState
9
+ import com.margelo.nitro.nitroplayer.media.NitroCastConfig
10
+
11
+ /**
12
+ * Google Cast backend integration for [TrackPlayerCore].
13
+ *
14
+ * Casting is modelled as a *backend swap*: the active Media3 [Player] wrapped by
15
+ * [ExoPlayerCore] is switched from the local `ExoPlayer` to the `CastPlayer` (and
16
+ * back) while keeping all queue state, listeners and the [androidx.media3.session.MediaSession]
17
+ * pointed at whichever player is active. Because both implement [Player], the rest
18
+ * of the core is unaware of which backend is playing.
19
+ *
20
+ * All functions here run on the player (main) looper.
21
+ */
22
+
23
+ // ── Backend switching ──────────────────────────────────────────────────────
24
+
25
+ internal fun TrackPlayerCore.switchToCastPlayer() {
26
+ val controller = castSessionController ?: return
27
+ switchToPlayer(controller.castPlayer)
28
+ isCastingField = true
29
+ notifyCastStateChange(CastState.CONNECTED, controller.currentDeviceName())
30
+ }
31
+
32
+ internal fun TrackPlayerCore.switchToLocalPlayer() {
33
+ val controller = castSessionController ?: return
34
+ switchToPlayer(controller.localPlayer)
35
+ isCastingField = false
36
+ notifyCastStateChange(controller.getCastState(), controller.currentDeviceName())
37
+ }
38
+
39
+ private fun TrackPlayerCore.switchToPlayer(target: Player) {
40
+ if (!isExoInitialized) return
41
+ val current = exo.player
42
+ if (current === target) return
43
+
44
+ // Move listener, queue and position from the current player to the target,
45
+ // then make the target authoritative for the MediaSession & notification.
46
+ playerListener?.let { current.removeListener(it) }
47
+ transferPlaybackState(current, target)
48
+ current.playWhenReady = false // silence the player we're leaving
49
+
50
+ exo = ExoPlayerCore(target)
51
+ playerListener?.let { target.addListener(it) }
52
+
53
+ try {
54
+ castSessionController?.let { it.mediaSession.player = target }
55
+ } catch (e: Exception) {
56
+ NitroPlayerLogger.log("TrackPlayerCast") { "setPlayer on MediaSession failed: ${e.message}" }
57
+ }
58
+ }
59
+
60
+ /** Copy the queue, index, position, repeat mode and play-when-ready from one player to another. */
61
+ private fun transferPlaybackState(
62
+ from: Player,
63
+ to: Player,
64
+ ) {
65
+ val count = from.mediaItemCount
66
+ if (count == 0) return
67
+ val items = ArrayList<MediaItem>(count)
68
+ for (i in 0 until count) items.add(from.getMediaItemAt(i))
69
+ val index = from.currentMediaItemIndex.coerceAtLeast(0)
70
+ val position = if (from.currentPosition >= 0) from.currentPosition else 0L
71
+
72
+ to.setMediaItems(items, index, position)
73
+ to.repeatMode = from.repeatMode
74
+ to.playWhenReady = from.playWhenReady
75
+ to.prepare()
76
+ }
77
+
78
+ // ── Notifications ──────────────────────────────────────────────────────────
79
+
80
+ internal fun TrackPlayerCore.notifyCastStateChange(
81
+ state: CastState,
82
+ deviceName: String?,
83
+ ) {
84
+ onCastStateChangeListeners.forEach { it(state, deviceName) }
85
+ }
86
+
87
+ // ── Public accessors (used by HybridCast) ──────────────────────────────────
88
+
89
+ internal fun TrackPlayerCore.isCasting(): Boolean = isCastingField
90
+
91
+ internal fun TrackPlayerCore.getCastState(): CastState =
92
+ if (isCastingField) {
93
+ CastState.CONNECTED
94
+ } else {
95
+ castSessionController?.getCastState() ?: CastState.NO_DEVICES_AVAILABLE
96
+ }
97
+
98
+ internal fun TrackPlayerCore.getCastDeviceName(): String? = castSessionController?.currentDeviceName()
99
+
100
+ internal fun TrackPlayerCore.endCastSession() {
101
+ castSessionController?.endCurrentSession()
102
+ }
103
+
104
+ internal fun TrackPlayerCore.showCastPicker(activity: Activity) {
105
+ val controller = castSessionController
106
+ if (controller == null) {
107
+ NitroPlayerLogger.log("TrackPlayerCast") { "showCastPicker ignored — Cast unavailable" }
108
+ return
109
+ }
110
+ controller.showCastPicker(activity)
111
+ }
112
+
113
+ internal fun TrackPlayerCore.configureCast(receiverApplicationId: String?) {
114
+ if (receiverApplicationId != null) {
115
+ NitroCastConfig.receiverApplicationId = receiverApplicationId
116
+ if (castSessionController != null) {
117
+ NitroPlayerLogger.log("TrackPlayerCast") {
118
+ "configureCast called after CastContext init — receiver ID takes effect on next app launch"
119
+ }
120
+ }
121
+ }
122
+ }
@@ -90,6 +90,15 @@ class TrackPlayerCore private constructor(
90
90
  ListenerRegistry<(List<TrackItem>, List<TrackItem>) -> Unit>()
91
91
  internal val onAndroidAutoConnectionListeners =
92
92
  ListenerRegistry<(Boolean) -> Unit>()
93
+ internal val onCastStateChangeListeners =
94
+ ListenerRegistry<(com.margelo.nitro.nitroplayer.CastState, String?) -> Unit>()
95
+
96
+ // ── Google Cast ──────────────────────────────────────────────────────────
97
+ /** Set once the playback service has a usable CastContext; null when Cast is unavailable. */
98
+ internal var castSessionController: com.margelo.nitro.nitroplayer.media.CastSessionController? = null
99
+
100
+ /** True while the active backend is the CastPlayer (audio routed to a remote device). */
101
+ @Volatile internal var isCastingField: Boolean = false
93
102
 
94
103
  // ── Progress & playlist-update runnables ───────────────────────────────
95
104
  internal val progressUpdateRunnable =
@@ -294,4 +303,8 @@ class TrackPlayerCore private constructor(
294
303
  fun addOnAndroidAutoConnectionListener(cb: (Boolean) -> Unit): Long = onAndroidAutoConnectionListeners.add(cb)
295
304
 
296
305
  fun removeOnAndroidAutoConnectionListener(id: Long): Boolean = onAndroidAutoConnectionListeners.remove(id)
306
+
307
+ fun addOnCastStateChangeListener(cb: (com.margelo.nitro.nitroplayer.CastState, String?) -> Unit): Long = onCastStateChangeListeners.add(cb)
308
+
309
+ fun removeOnCastStateChangeListener(id: Long): Boolean = onCastStateChangeListeners.remove(id)
297
310
  }
@@ -28,6 +28,13 @@ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.
28
28
  playerListener = listener
29
29
  exo.addListener(listener)
30
30
 
31
+ // Wire Google Cast (if available on this device) so sessions can swap the
32
+ // active player to/from the CastPlayer.
33
+ binder.castController?.let { controller ->
34
+ castSessionController = controller
35
+ controller.attachCore(this)
36
+ }
37
+
31
38
  // The audio session ID is assigned during ExoPlayer construction (in
32
39
  // PlaybackService.onCreate), before our listener is attached.
33
40
  // onAudioSessionIdChanged only fires on *changes*, so we'd miss the
@@ -0,0 +1,160 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.media
4
+
5
+ import android.app.Activity
6
+ import android.os.Handler
7
+ import androidx.media3.cast.CastPlayer
8
+ import androidx.media3.cast.SessionAvailabilityListener
9
+ import androidx.media3.common.Player
10
+ import androidx.media3.common.util.UnstableApi
11
+ import androidx.mediarouter.app.MediaRouteChooserDialog
12
+ import androidx.mediarouter.app.MediaRouteControllerDialog
13
+ import com.google.android.gms.cast.framework.CastContext
14
+ import com.google.android.gms.cast.framework.CastStateListener
15
+ import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
16
+ import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
17
+ import com.margelo.nitro.nitroplayer.core.notifyCastStateChange
18
+ import com.margelo.nitro.nitroplayer.core.switchToCastPlayer
19
+ import com.margelo.nitro.nitroplayer.core.switchToLocalPlayer
20
+ import androidx.media3.session.MediaSession
21
+ import com.google.android.gms.cast.framework.CastState as GmsCastState
22
+ import com.margelo.nitro.nitroplayer.CastState
23
+
24
+ /**
25
+ * Owns the Google Cast [CastPlayer] and bridges Cast session lifecycle into the
26
+ * player core.
27
+ *
28
+ * Created by [NitroPlayerPlaybackService] once a `CastContext` is available, then
29
+ * wired to [TrackPlayerCore] via [attachCore]. When a Cast session starts, the
30
+ * core swaps its active player to [castPlayer] (see `TrackPlayerCast`), so audio
31
+ * plays only on the remote device; on disconnect it swaps back to [localPlayer].
32
+ */
33
+ @UnstableApi
34
+ class CastSessionController(
35
+ private val castContext: CastContext,
36
+ val castPlayer: CastPlayer,
37
+ val localPlayer: Player,
38
+ val mediaSession: MediaSession,
39
+ private val mainHandler: Handler,
40
+ ) {
41
+ private var core: TrackPlayerCore? = null
42
+
43
+ // Cast SDK reads (castState, current session) MUST happen on the main thread —
44
+ // it throws IllegalStateException otherwise. Nitro sync getters run on the JS
45
+ // thread, so we keep a main-thread-updated cache and serve the getters from it.
46
+ @Volatile private var cachedState: CastState = CastState.NO_DEVICES_AVAILABLE
47
+ @Volatile private var cachedDeviceName: String? = null
48
+
49
+ private val castStateListener =
50
+ CastStateListener {
51
+ val c = core ?: return@CastStateListener
52
+ mainHandler.post {
53
+ refreshCacheOnMain()
54
+ c.notifyCastStateChange(cachedState, cachedDeviceName)
55
+ }
56
+ }
57
+
58
+ private val sessionAvailabilityListener =
59
+ object : SessionAvailabilityListener {
60
+ override fun onCastSessionAvailable() {
61
+ mainHandler.post {
62
+ refreshCacheOnMain()
63
+ core?.switchToCastPlayer()
64
+ }
65
+ }
66
+
67
+ override fun onCastSessionUnavailable() {
68
+ mainHandler.post {
69
+ refreshCacheOnMain()
70
+ core?.switchToLocalPlayer()
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Wire this controller to the core and start listening for Cast events. */
76
+ fun attachCore(core: TrackPlayerCore) {
77
+ this.core = core
78
+ castPlayer.setSessionAvailabilityListener(sessionAvailabilityListener)
79
+ castContext.addCastStateListener(castStateListener)
80
+
81
+ // If a session is already live (e.g. resumed on app launch), adopt it.
82
+ if (castPlayer.isCastSessionAvailable) {
83
+ mainHandler.post { core.switchToCastPlayer() }
84
+ }
85
+ // Emit the current state so JS reflects reality immediately.
86
+ mainHandler.post {
87
+ refreshCacheOnMain()
88
+ core.notifyCastStateChange(cachedState, cachedDeviceName)
89
+ }
90
+ }
91
+
92
+ /** Cached value — safe to call from the JS thread. */
93
+ fun getCastState(): CastState = cachedState
94
+
95
+ /** Cached value — safe to call from the JS thread. */
96
+ fun currentDeviceName(): String? = cachedDeviceName
97
+
98
+ /** Re-read the live Cast state into the cache. MUST run on the main thread. */
99
+ private fun refreshCacheOnMain() {
100
+ cachedState =
101
+ try {
102
+ mapCastState(castContext.castState)
103
+ } catch (_: Exception) {
104
+ cachedState
105
+ }
106
+ cachedDeviceName =
107
+ try {
108
+ castContext.sessionManager.currentCastSession?.castDevice?.friendlyName
109
+ } catch (_: Exception) {
110
+ null
111
+ }
112
+ }
113
+
114
+ fun endCurrentSession() {
115
+ mainHandler.post {
116
+ try {
117
+ castContext.sessionManager.endCurrentSession(true)
118
+ } catch (e: Exception) {
119
+ NitroPlayerLogger.log("CastSessionController") { "endCurrentSession failed: ${e.message}" }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Present the native Cast dialog: the device chooser when not connected, or
126
+ * the route controller (with disconnect) when already casting. Requires a
127
+ * foreground [Activity].
128
+ */
129
+ fun showCastPicker(activity: Activity) {
130
+ try {
131
+ if (castContext.castState == GmsCastState.CONNECTED) {
132
+ MediaRouteControllerDialog(activity).show()
133
+ } else {
134
+ val selector = castContext.mergedSelector ?: return
135
+ MediaRouteChooserDialog(activity).apply {
136
+ routeSelector = selector
137
+ }.show()
138
+ }
139
+ } catch (e: Exception) {
140
+ NitroPlayerLogger.log("CastSessionController") { "showCastPicker failed: ${e.message}" }
141
+ }
142
+ }
143
+
144
+ fun release() {
145
+ try {
146
+ castPlayer.setSessionAvailabilityListener(null)
147
+ castContext.removeCastStateListener(castStateListener)
148
+ castPlayer.release()
149
+ } catch (_: Exception) {
150
+ }
151
+ }
152
+
153
+ private fun mapCastState(state: Int): CastState =
154
+ when (state) {
155
+ GmsCastState.NO_DEVICES_AVAILABLE -> CastState.NO_DEVICES_AVAILABLE
156
+ GmsCastState.CONNECTING -> CastState.CONNECTING
157
+ GmsCastState.CONNECTED -> CastState.CONNECTED
158
+ else -> CastState.NOT_CONNECTED
159
+ }
160
+ }
@@ -0,0 +1,23 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import com.google.android.gms.cast.CastMediaControlIntent
4
+
5
+ /**
6
+ * Process-wide Google Cast configuration.
7
+ *
8
+ * The Cast receiver application ID must be known *before* `CastContext` is
9
+ * initialized (it is a singleton, fixed for the process lifetime). Callers set
10
+ * [receiverApplicationId] via `Cast.configure(...)` as early as possible; the
11
+ * [NitroCastOptionsProvider] reads it at init time. When unset, the platform
12
+ * Default Media Receiver is used, which supports audio playback, metadata and
13
+ * queues out of the box.
14
+ */
15
+ object NitroCastConfig {
16
+ @Volatile
17
+ var receiverApplicationId: String? = null
18
+
19
+ /** The effective receiver ID, falling back to the Default Media Receiver. */
20
+ val effectiveReceiverId: String
21
+ get() = receiverApplicationId
22
+ ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
23
+ }