react-native-nitro-player 1.4.4-alpha.0 → 1.5.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NitroPlayer.podspec +6 -0
- package/README.md +115 -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 +373 -0
- package/ios/core/TrackPlayerCast.swift +109 -0
- package/ios/core/TrackPlayerCore.swift +23 -2
- package/ios/core/TrackPlayerPlayback.swift +32 -0
- package/ios/core/TrackPlayerQueue.swift +8 -1
- package/ios/core/TrackPlayerQueueBuild.swift +21 -0
- package/lib/components/CastButton.d.ts +44 -0
- package/lib/components/CastButton.js +125 -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 +233 -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
package/NitroPlayer.podspec
CHANGED
|
@@ -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,118 @@ 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
|
+
<!-- Receiver-specific service. Use _CC1AD845._googlecast._tcp for the Default
|
|
681
|
+
Media Receiver, or _<YOUR_RECEIVER_APP_ID>._googlecast._tcp for a custom one. -->
|
|
682
|
+
<string>_CC1AD845._googlecast._tcp</string>
|
|
683
|
+
</array>
|
|
684
|
+
<!-- Only if you use Cast guest mode (full SDK): -->
|
|
685
|
+
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
686
|
+
<string>${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices.</string>
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
The Google Cast SDK is pulled in automatically via the pod dependency — just run
|
|
690
|
+
`pod install`. No `AppDelegate` code is required; the library initializes the Cast
|
|
691
|
+
context on launch (with the Default Media Receiver, or your `Cast.configure` ID).
|
|
692
|
+
|
|
693
|
+
**Android** — no app code required. The library declares the Cast `OptionsProvider`
|
|
694
|
+
and discovery permissions in its manifest (merged automatically) and initializes the
|
|
695
|
+
Cast framework from its playback service at launch. Cast device discovery requires
|
|
696
|
+
Google Play services on the device (unavailable on most emulators).
|
|
697
|
+
|
|
698
|
+
> Apps that also use another Cast `OptionsProvider` (e.g. `react-native-google-cast`)
|
|
699
|
+
> must override the merged meta-data with `tools:replace`. To set a custom receiver
|
|
700
|
+
> ID without `Cast.configure`, override the
|
|
701
|
+
> `com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME` meta-data with
|
|
702
|
+
> your own provider.
|
|
703
|
+
|
|
704
|
+
### Notes & limitations
|
|
705
|
+
|
|
706
|
+
- **Testing requires a real Cast device** on the same Wi-Fi network — there is no
|
|
707
|
+
simulator/emulator support.
|
|
708
|
+
- The full effective queue (playlist + play-next + up-next) is mirrored to the
|
|
709
|
+
device, so skip / previous / seek work remotely.
|
|
710
|
+
- On iOS, while casting the lock-screen Now Playing metadata is best-effort; the
|
|
711
|
+
transport buttons themselves control the Cast device.
|
|
712
|
+
- If a device has no Google Play services (Android) or the SDK is otherwise
|
|
713
|
+
unavailable, Cast features degrade to inert no-ops (`isCasting` stays `false`).
|
|
714
|
+
|
|
601
715
|
## Equalizer
|
|
602
716
|
|
|
603
717
|
The player includes a powerful 5-band equalizer that works on both iOS and Android.
|
|
@@ -1121,6 +1235,7 @@ AndroidAutoMediaLibraryHelper.set({
|
|
|
1121
1235
|
- ✅ **Notification Controls**: Show playback controls in notifications
|
|
1122
1236
|
- ✅ **Progress Tracking**: Real-time playback progress updates
|
|
1123
1237
|
- ✅ **Offline Downloads**: Download tracks and playlists for offline playback
|
|
1238
|
+
- ✅ **Google Cast**: Seamless casting to Chromecast / Google TV (iOS & Android)
|
|
1124
1239
|
|
|
1125
1240
|
## TypeScript Support
|
|
1126
1241
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
16
|
+
player: Player,
|
|
14
17
|
) {
|
|
15
|
-
/** The underlying
|
|
16
|
-
internal val player:
|
|
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
|
+
}
|