chromecast test

This commit is contained in:
tapframe 2025-10-25 01:01:10 +05:30
parent 94e165f0b0
commit f126f81c13
21 changed files with 1105 additions and 19 deletions

View file

@ -1,4 +1,9 @@
apply plugin: "com.android.application"
// @generated begin safeExtGet - expo prebuild (DO NOT MODIFY) sync-be4acad6508a6820102c74ab393bd7ab1093e6c0
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// @generated end safeExtGet
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
@ -185,6 +190,9 @@ android {
}
dependencies {
// @generated begin react-native-google-cast-dependencies - expo prebuild (DO NOT MODIFY) sync-3822a3c86222e7aca74039b551612aab7e75365d
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
// @generated end react-native-google-cast-dependencies
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
@ -214,4 +222,7 @@ dependencies {
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
implementation files("libs/lib-decoder-ffmpeg-release.aar")
// Google Cast SDK
implementation "com.google.android.gms:play-services-cast-framework:+"
}

View file

@ -14,6 +14,8 @@
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="com.reactnative.googlecast.GoogleCastOptionsProvider"/>
<meta-data android:name="com.reactnative.googlecast.RECEIVER_APPLICATION_ID" android:value="CC1AD845"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>

View file

@ -9,6 +9,7 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnable
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
import com.reactnative.googlecast.api.RNGCCastContext
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -17,6 +18,12 @@ class MainActivity : ReactActivity() {
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
// @generated begin react-native-google-cast-onCreate - expo prebuild (DO NOT MODIFY) sync-489050f2bf9933a98bbd9d93137016ae14c22faa
RNGCCastContext.getSharedInstance(this)
// @generated end react-native-google-cast-onCreate
// Initialize Google Cast context
RNGCCastContext.getSharedInstance(this)
}
/**

View file

@ -1,6 +1,11 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
// @generated begin react-native-google-cast-version-import - expo prebuild (DO NOT MODIFY) sync-751dea5919495636a44001495c681e3442af2777
ext {
castFrameworkVersion = "+"
}
// @generated end react-native-google-cast-version-import
repositories {
google()
mavenCentral()

View file

@ -87,6 +87,14 @@
"supportsBackgroundPlayback": true
}
],
[
"react-native-google-cast",
{
"androidReceiverAppId": "CC1AD845",
"iosReceiverAppId": "CC1AD845",
"iosStartDiscoveryAfterFirstTapOnCastButton": true
}
],
"react-native-bottom-tabs"
],
"updates": {

242
cast.md Normal file
View file

@ -0,0 +1,242 @@
nstallation
$ npm install react-native-google-cast --save
or
$ yarn add react-native-google-cast
Expo
Since Expo SDK 42, you can use this library in a custom-built Expo app. There is a config plugin included to auto-configure react-native-google-cast when the native code is generated (npx expo prebuild).
This package cannot be used in Expo Go because it requires custom native code. You need to build a standalone app instead.
Add the config plugin to the plugins array of your app.json or app.config.js/ts:
{
"expo": {
"plugins": ["react-native-google-cast"]
}
}
Next, rebuild your app as described in the "Adding custom native code" guide.
Then ignore the rest of this page and continue to Setup.
iOS
Thanks to autolinking, the package and its Google Cast SDK dependency are automatically installed when you run pod install.
The latest Google Cast SDK (currently 4.8.3) requires iOS 14 or newer. However, React Native 0.76+ already requires iOS 15.1 or higher. If you need to support older iOS versions, use an older version of the library but note that some features might not be available.
Before v4.8.1, Google Cast used to publish different variants of the SDK based on whether they included Guest Mode support. That feature has been removed in the latest versions so now there's only a single SDK variant.
Android
The react-native-google-cast library is autolinked but we need to add the Google Cast SDK dependency to android/app/build.gradle:
dependencies {
// ...
implementation "com.google.android.gms:play-services-cast-framework:+"
}
By default, the latest version (+) of the Cast SDK is used.
To use a specific version, add castFrameworkVersion in the root android/build.gradle:
buildscript {
ext {
buildToolsVersion = "34.0.0"
minSdkVersion = 22
compileSdkVersion = 34
targetSdkVersion = 34
castFrameworkVersion = "22.1.0" // <-- Cast SDK version
}
}
and update android/app/build.gradle:
dependencies {
// ...
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
}
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
etup
Expo
If you're using Expo, you can configure your build using the included plugin (see below) and then continue to Usage.
The plugin provides props for extra customization. Every time you change the props or plugins, you'll need to rebuild (and prebuild) the native app. If no extra properties are added, defaults will be used.
receiverAppId (string): custom receiver app id. Default CC1AD845 (default receiver provided by Google). Sets both iosReceiverAppId and androidReceiverAppId.
expandedController (boolean): Whether to use the default expanded controller. Default true.
androidReceiverAppId (string): custom receiver app id. Default CC1AD845.
androidPlayServicesCastFrameworkVersion (string): Version for the Android Cast SDK. Default + (latest).
iosReceiverAppId (string): custom receiver app id. Default CC1AD845.
iosDisableDiscoveryAutostart (boolean): Whether the discovery of Cast devices should not start automatically at context initialization time. Default false. if set to true, you'll need to start it later by calling DiscoveryManager.startDiscovery.
iosStartDiscoveryAfterFirstTapOnCastButton (boolean): Whether cast devices discovery start only after a user taps on the Cast button for the first time. Default true. If set to false, discovery will start as soon as the SDK is initialized. Note that this will ask the user for network permissions immediately when the app is opened for the first time.
iosSuspendSessionsWhenBackgrounded (boolean): Whether sessions should be suspended when the sender application goes into the background (and resumed when it returns to the foreground). Default true. It is appropriate to set this to false in applications that are able to maintain network connections indefinitely while in the background.
{
"expo": {
"plugins": [
[
"react-native-google-cast",
{
"receiverAppId": "...",
"iosStartDiscoveryAfterFirstTapOnCastButton": false
}
]
]
}
}
iOS
In AppDelegate.swift (or AppDelegate.mm) add
Swift
Objective-C
// 1.1. add import at the top
import GoogleCast
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// ...
// 1.2. add inside application:didFinishLaunchingWithOptions
let receiverAppID = kGCKDefaultMediaReceiverApplicationID // or "ABCD1234"
let criteria = GCKDiscoveryCriteria(applicationID: receiverAppID)
let options = GCKCastOptions(discoveryCriteria: criteria)
GCKCastContext.setSharedInstanceWith(options)
// ...
}
// ...
}
If using a custom web receiver, replace kGCKDefaultMediaReceiverApplicationID with your receiver app id.
You need to add local network permissions to Info.plist:
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
If using a custom receiver, make sure to replace CC1AD845 with your custom receiver app id.
You may also customize the local network usage description (See #355).
Furthermore, a dialog asking the user for the local network permission will now be displayed immediately when the app is opened.
(optional) By default, Cast device discovery is initiated when the user taps the Cast button. If it's the first time, the local network access interstitial will appear, followed by the iOS Local Network Access permissions dialog.
You may customize this behavior in AppDelegate.m by either:
setting disableDiscoveryAutostart to true:
options.disableDiscoveryAutostart = true
Note: If you disable discovery autostart, you'll need to start it later by calling startDiscovery.
or setting startDiscoveryAfterFirstTapOnCastButton to false. In this case, discovery will start as soon as the SDK is initialized.
options.startDiscoveryAfterFirstTapOnCastButton = false
Android
Add to AndroidManifest.xml (in android/app/src/main):
<application ...>
...
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.reactnative.googlecast.GoogleCastOptionsProvider" />
</application>
Additionally, if you're using a custom receiver, also add (replace ABCD1234 with your receiver app id):
<meta-data
android:name="com.reactnative.googlecast.RECEIVER_APPLICATION_ID"
android:value="ABCD1234" />
Alternatively, you may provide your own OptionsProvider class. See GoogleCastOptionsProvider.java for inspiration.
In your MainActivity.kt or MainActivity.java, initialize CastContext by overriding the onCreate method.
Kotlin
Java
import android.os.Bundle
import androidx.annotation.Nullable
import com.reactnative.googlecast.api.RNGCCastContext
class MainActivity : ReactActivity() {
// ...
override fun onCreate(@Nullable savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// lazy load Google Cast context (if supported on this device)
RNGCCastContext.getSharedInstance(this)
}
}
This works if you're extending ReactActivity (or NavigationActivity if you're using react-native-navigation). If you're extending a different activity, make sure it is a descendant of androidx.appcompat.app.AppCompatActivity.
The Cast framework requires Google Play Services to be available on your device. If your device doesn't have them by default, you can install them either from the Play Store, from OpenGApps or follow tutorials online.
Usage
First, render Cast button which handles session and enables users to connect to Cast devices. You can then get the current connected client, and call loadMedia as needed.
import React from 'react'
import { CastButton, useRemoteMediaClient } from 'react-native-google-cast'
function MyComponent() {
// This will automatically rerender when client is connected to a device
// (after pressing the button that's rendered below)
const client = useRemoteMediaClient()
if (client) {
// Send the media to your Cast device as soon as we connect to a device
// (though you'll probably want to call this later once user clicks on a video or something)
client.loadMedia({
mediaInfo: {
contentUrl:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/mp4/BigBuckBunny.mp4',
contentType: 'video/mp4',
},
})
}
// This will render native Cast button.
// When a user presses it, a Cast dialog will prompt them to select a Cast device to connect to.
return <CastButton style={{ width: 24, height: 24, tintColor: 'black' }} />
}
You can provide many different attributes, such as in this example:
client.loadMedia({
mediaInfo: {
contentUrl:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/mp4/BigBuckBunny.mp4',
contentType: 'video/mp4',
metadata: {
images: [
{
url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/images/480x270/BigBuckBunny.jpg',
},
],
title: 'Big Buck Bunny',
subtitle:
'A large and lovable rabbit deals with three tiny bullies, led by a flying squirrel, who are determined to squelch his happiness.',
studio: 'Blender Foundation',
type: 'movie',
},
streamDuration: 596, // seconds
},
startTime: 10, // seconds
})
Please see the MediaLoadRequest documentation for available options.
(Android) Handle missing Google Play Services
On Android, you can use CastContext.getPlayServicesState() to check if Google Play Services are installed on the device. You can then call CastContext.showPlayServicesErrorDialog to inform the user and prompt them to install.
CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state)
})

View file

@ -297,6 +297,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/KSPlayer/KSPlayer_KSPlayer.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
@ -325,6 +326,14 @@
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleCastCoreResources.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleCastUIResources.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleCastOptionalUIResources.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/MaterialDialogs.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleSansBold.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleSansMedium.bundle",
"${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.4_static_xcframework/GoogleCast.xcframework/ios-arm64/GoogleCast.framework/GoogleSansRegular.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/google-cast-sdk/GoogleCast.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-react-native/Lottie_React_Native_Privacy.bundle",
);
@ -340,6 +349,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/KSPlayer_KSPlayer.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
@ -368,6 +378,14 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastCoreResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastUIResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastOptionalUIResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialDialogs.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSansBold.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSansMedium.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSansRegular.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCast.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle",
);
@ -461,7 +479,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = "Nuvio";
PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -492,8 +510,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -1,4 +1,9 @@
import Expo
// @generated begin react-native-google-cast-import - expo prebuild (DO NOT MODIFY) sync-4cd300bca26a1d1fcc83f4baf37b0e62afcc1867
#if canImport(GoogleCast) && os(iOS)
import GoogleCast
#endif
// @generated end react-native-google-cast-import
import React
import ReactAppDependencyProvider
@ -13,6 +18,18 @@ public class AppDelegate: ExpoAppDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-3f476aa248b3451597781fe1ea72c7d4127ed7f9
#if canImport(GoogleCast) && os(iOS)
let receiverAppID = "CC1AD845"
let criteria = GCKDiscoveryCriteria(applicationID: receiverAppID)
let options = GCKCastOptions(discoveryCriteria: criteria)
options.disableDiscoveryAutostart = false
options.startDiscoveryAfterFirstTapOnCastButton = true
options.suspendSessionsWhenBackgrounded = true
GCKCastContext.setSharedInstanceWith(options)
GCKCastContext.sharedInstance().useDefaultExpandedMediaControls = true
#endif
// @generated end react-native-google-cast-didFinishLaunchingWithOptions
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()

View file

@ -54,6 +54,8 @@
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>

View file

@ -20,6 +20,7 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>C56D.1</string>
</array>
</dict>
<dict>

View file

@ -328,6 +328,7 @@ PODS:
- FFmpegKit/FFmpegKit (= 6.1.0)
- FFmpegKit/FFmpegKit (6.1.0):
- Libass
- google-cast-sdk (4.8.4)
- hermes-engine (0.81.4):
- hermes-engine/Pre-built (= 0.81.4)
- hermes-engine/Pre-built (0.81.4)
@ -405,6 +406,7 @@ PODS:
- ReactNativeDependencies
- Yoga
- MobileVLCKit (3.6.1b1)
- PromisesObjC (2.4.0)
- RCTDeprecation (0.81.4)
- RCTRequired (0.81.4)
- RCTTypeSafety (0.81.4):
@ -1758,6 +1760,10 @@ PODS:
- React
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-google-cast (4.9.1):
- google-cast-sdk
- PromisesObjC
- React
- react-native-netinfo (11.4.1):
- React-Core
- react-native-safe-area-context (5.6.1):
@ -2763,6 +2769,7 @@ DEPENDENCIES:
- react-native-bottom-tabs (from `../node_modules/react-native-bottom-tabs`)
- "react-native-device-brightness (from `../node_modules/@adrianso/react-native-device-brightness`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
@ -2812,11 +2819,13 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- google-cast-sdk
- libavif
- libdav1d
- libwebp
- lottie-ios
- MobileVLCKit
- PromisesObjC
- ReachabilitySwift
- SDWebImage
- SDWebImageAVIFCoder
@ -2990,6 +2999,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@adrianso/react-native-device-brightness"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-google-cast:
:path: "../node_modules/react-native-google-cast"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-safe-area-context:
@ -3136,6 +3147,7 @@ SPEC CHECKSUMS:
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42
FFmpegKit: 3885085fbbc320745838ee4c8a1f9c5e5953dab2
google-cast-sdk: 32f65af50d164e3c475e79ad123db3cc26fbcd37
hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394
ImageColors: 51cd79f7a9d2524b7a681c660b0a50574085563b
KSPlayer: f163ac6195f240b6fa5b8225aeb39ec811a70c62
@ -3146,6 +3158,7 @@ SPEC CHECKSUMS:
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
MobileVLCKit: 2d9c7c373393ae43086aeeff890bf0b1afc15c5c
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc
RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157
RCTTypeSafety: d2b07797a79e45d7b19e1cd2f53c79ab419fe217
@ -3184,6 +3197,7 @@ SPEC CHECKSUMS:
react-native-bottom-tabs: e37c9d1565b1ee48c4c0e4b4fa4b804775f82dfa
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
react-native-slider: 8c562583722c396a3682f451f0b6e68e351ec3b9

11
package-lock.json generated
View file

@ -71,6 +71,7 @@
"react-native-bottom-tabs": "^0.12.2",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2",
"react-native-markdown-display": "^7.0.2",
@ -10801,6 +10802,16 @@
"react-native": ">=0.56"
}
},
"node_modules/react-native-google-cast": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/react-native-google-cast/-/react-native-google-cast-4.9.1.tgz",
"integrity": "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-image-colors": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.5.0.tgz",

View file

@ -71,6 +71,7 @@
"react-native-bottom-tabs": "^0.12.2",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.9.1",
"react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2",
"react-native-markdown-display": "^7.0.2",

View file

@ -96,8 +96,8 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
// Robustly determine if the item is in the library (saved)
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
const isWatched = !!isWatchedProp;
const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type);
const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type);
const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show');
const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show');
let menuOptions = [
{

View file

@ -18,6 +18,7 @@ import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
import { useMetadata } from '../../hooks/useMetadata';
import { useSettings } from '../../hooks/useSettings';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
import { useChromecast } from '../../hooks/useChromecast';
import {
DEFAULT_SUBTITLE_SIZE,
@ -210,6 +211,11 @@ const AndroidVideoPlayer: React.FC = () => {
const initialSeekTargetRef = useRef<number | null>(null);
const initialSeekVerifiedRef = useRef(false);
const isSourceSeekableRef = useRef<boolean | null>(null);
// Cast-related state
const [isCasting, setIsCasting] = useState(false);
const [wasPlayingBeforeCast, setWasPlayingBeforeCast] = useState(false);
const [castPositionBeforeConnect, setCastPositionBeforeConnect] = useState(0);
const fadeAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const openingFadeAnim = useRef(new Animated.Value(0)).current;
@ -485,6 +491,125 @@ const AndroidVideoPlayer: React.FC = () => {
const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
// Initialize Chromecast hook (after all required state variables are declared)
const chromecast = useChromecast();
// Cast handler functions
const handleCastPress = useCallback(() => {
if (!chromecast.isCastAvailable) {
logger.warn('[AndroidVideoPlayer] Chromecast not available');
return;
}
if (chromecast.isCastConnected) {
// Already connected, show picker to disconnect or switch devices
chromecast.showCastPicker();
} else {
// Not connected, show picker to connect
chromecast.showCastPicker();
}
}, [chromecast]);
const loadMediaToCast = useCallback(async () => {
if (!chromecast.isCastConnected || !currentStreamUrl) {
return;
}
try {
// Determine content type based on stream URL
let contentType = 'video/mp4';
if (currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('hls')) {
contentType = 'application/x-mpegURL';
} else if (currentStreamUrl.includes('.mpd') || currentStreamUrl.includes('dash')) {
contentType = 'application/dash+xml';
}
// Prepare metadata
const metadata = {
title: title,
subtitle: episodeTitle || `${season ? `S${season}` : ''}${episode ? `E${episode}` : ''}`,
images: backdrop ? [{ url: backdrop }] : undefined,
studio: currentStreamProvider || streamProvider || 'Nuvio',
type: type === 'series' ? 'series' as const : 'movie' as const,
};
const mediaInfo = {
contentUrl: currentStreamUrl,
contentType,
metadata,
streamDuration: duration,
startTime: currentTime,
customData: {
headers: headers,
quality: currentQuality || quality,
streamProvider: currentStreamProvider || streamProvider,
}
};
await chromecast.loadMedia(mediaInfo);
// Store state before casting
setWasPlayingBeforeCast(!paused);
setCastPositionBeforeConnect(currentTime);
setIsCasting(true);
// Pause local playback
setPaused(true);
logger.log('[AndroidVideoPlayer] Media loaded to Cast device');
} catch (error) {
logger.error('[AndroidVideoPlayer] Error loading media to Cast:', error);
}
}, [chromecast, currentStreamUrl, title, episodeTitle, season, episode, backdrop, currentStreamProvider, streamProvider, type, duration, currentTime, headers, currentQuality, quality, paused]);
// Handle Cast connection changes
useEffect(() => {
if (chromecast.isCastConnected && !isCasting) {
// Just connected to Cast device, load media
loadMediaToCast();
} else if (!chromecast.isCastConnected && isCasting) {
// Disconnected from Cast device, resume local playback
setIsCasting(false);
// Resume local playback at Cast's last position
if (chromecast.currentPosition > 0) {
setCurrentTime(chromecast.currentPosition);
// Seek to the position if video is loaded
if (videoRef.current && duration > 0) {
videoRef.current.seek(chromecast.currentPosition);
}
}
// Restore playing state if it was playing before casting
if (wasPlayingBeforeCast) {
setPaused(false);
}
logger.log('[AndroidVideoPlayer] Resumed local playback after Cast disconnect');
}
}, [chromecast.isCastConnected, isCasting, loadMediaToCast, chromecast.currentPosition, duration, wasPlayingBeforeCast]);
// Sync Cast playback state with local state
useEffect(() => {
if (isCasting && chromecast.isCastConnected) {
// Update current time from Cast device
if (chromecast.currentPosition !== currentTime) {
setCurrentTime(chromecast.currentPosition);
}
// Update duration from Cast device
if (chromecast.duration !== duration && chromecast.duration > 0) {
setDuration(chromecast.duration);
}
// Sync paused state
if (chromecast.isPlaying !== !paused) {
setPaused(!chromecast.isPlaying);
}
}
}, [isCasting, chromecast.isCastConnected, chromecast.currentPosition, chromecast.duration, chromecast.isPlaying, currentTime, duration, paused]);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
@ -1589,8 +1714,15 @@ const AndroidVideoPlayer: React.FC = () => {
const skip = useCallback((seconds: number) => {
const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON));
seekToTime(newTime);
}, [currentTime, duration]);
if (isCasting && chromecast.isCastConnected) {
// Forward seek command to Cast device
chromecast.seek(newTime);
} else {
// Local playback
seekToTime(newTime);
}
}, [isCasting, chromecast, currentTime, duration]);
const cycleAspectRatio = useCallback(() => {
// Prevent rapid successive resize operations
@ -2480,13 +2612,23 @@ const AndroidVideoPlayer: React.FC = () => {
};
const togglePlayback = useCallback(() => {
const newPausedState = !paused;
setPaused(newPausedState);
if (isCasting && chromecast.isCastConnected) {
// Forward play/pause command to Cast device
if (paused) {
chromecast.play();
} else {
chromecast.pause();
}
} else {
// Local playback
const newPausedState = !paused;
setPaused(newPausedState);
if (duration > 0) {
traktAutosync.handleProgressUpdate(currentTime, duration, true);
if (duration > 0) {
traktAutosync.handleProgressUpdate(currentTime, duration, true);
}
}
}, [paused, currentTime, duration, traktAutosync]);
}, [isCasting, chromecast, paused, duration, currentTime, traktAutosync]);
// Handle next episode button press
const handlePlayNextEpisode = useCallback(async () => {
@ -3466,7 +3608,11 @@ const AndroidVideoPlayer: React.FC = () => {
onSlidingComplete={handleSlidingComplete}
buffered={buffered}
formatTime={formatTime}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
// Cast props
isCastConnected={chromecast.isCastConnected}
castDevice={chromecast.castDevice?.name}
onCastPress={handleCastPress}
/>
{showPauseOverlay && (
@ -4041,6 +4187,55 @@ const AndroidVideoPlayer: React.FC = () => {
</Animated.View>
)}
{/* Cast Error Overlay */}
{chromecast.error && (
<Animated.View
style={{
position: 'absolute',
top: screenDimensions.height * 0.1,
left: screenDimensions.width / 2 - 100,
opacity: 1,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(255, 0, 0, 0.9)',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 8,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
maxWidth: 200,
}}>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
}}>
Cast Error: {chromecast.error}
</Text>
<TouchableOpacity
style={{ marginTop: 4 }}
onPress={chromecast.clearError}
>
<Text style={{
color: '#FFFFFF',
fontSize: 10,
textDecorationLine: 'underline',
}}>
Dismiss
</Text>
</TouchableOpacity>
</View>
</Animated.View>
)}
{/* Speed Activated Overlay */}
{showSpeedActivatedOverlay && (
<Animated.View

View file

@ -19,6 +19,7 @@ import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings';
import { useMetadata } from '../../hooks/useMetadata';
import { useSettings } from '../../hooks/useSettings';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
import { useChromecast } from '../../hooks/useChromecast';
import {
DEFAULT_SUBTITLE_SIZE,
@ -268,6 +269,12 @@ const KSPlayerCore: React.FC = () => {
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Cast state
const chromecast = useChromecast();
const [isCasting, setIsCasting] = useState(false);
const [wasPlayingBeforeCast, setWasPlayingBeforeCast] = useState(false);
const [castPositionBeforeConnect, setCastPositionBeforeConnect] = useState(0);
// Silent startup-timeout retry state
const startupRetryCountRef = useRef(0);
const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -1212,7 +1219,12 @@ const KSPlayerCore: React.FC = () => {
const skip = (seconds: number) => {
const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON));
seekToTime(newTime);
if (isCasting && chromecast.isCastConnected) {
chromecast.seek(newTime);
} else {
seekToTime(newTime);
}
};
const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => {
@ -1823,7 +1835,15 @@ const KSPlayerCore: React.FC = () => {
};
const togglePlayback = () => {
setPaused(!paused);
if (isCasting && chromecast.isCastConnected) {
if (paused) {
chromecast.play();
} else {
chromecast.pause();
}
} else {
setPaused(!paused);
}
};
// Handle next episode button press
@ -2309,6 +2329,57 @@ const KSPlayerCore: React.FC = () => {
}
};
// Cast handlers
const handleCastPress = useCallback(() => {
if (!chromecast.isCastAvailable) {
logger.warn('[KSPlayerCore] Chromecast not available');
return;
}
chromecast.showCastPicker();
}, [chromecast]);
const loadMediaToCast = useCallback(async () => {
if (!chromecast.isCastConnected || !currentStreamUrl) {
return;
}
try {
let contentType = 'video/mp4';
if (currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('hls')) {
contentType = 'application/x-mpegURL';
}
const mediaInfo = {
contentUrl: currentStreamUrl,
contentType,
metadata: {
title: title,
subtitle: episodeTitle || `${season ? `S${season}` : ''}${episode ? `E${episode}` : ''}`,
images: backdrop ? [{ url: backdrop }] : undefined,
studio: currentStreamProvider || streamProvider || 'Nuvio',
},
streamDuration: duration,
startTime: currentTime,
customData: {
headers: headers,
quality: currentQuality || quality,
}
};
await chromecast.loadMedia(mediaInfo);
setWasPlayingBeforeCast(!paused);
setCastPositionBeforeConnect(currentTime);
setIsCasting(true);
setPaused(true);
logger.log('[KSPlayerCore] Media loaded to Cast device');
} catch (error) {
logger.error('[KSPlayerCore] Error loading media to Cast:', error);
}
}, [chromecast, currentStreamUrl, title, episodeTitle, season, episode, backdrop,
currentStreamProvider, streamProvider, duration, currentTime, headers,
currentQuality, quality, paused]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
setShowSourcesModal(false);
@ -2398,6 +2469,43 @@ const KSPlayerCore: React.FC = () => {
}
}, [isVideoLoaded, initialPosition, duration]);
// Handle Cast connection changes
useEffect(() => {
if (chromecast.isCastConnected && !isCasting) {
loadMediaToCast();
} else if (!chromecast.isCastConnected && isCasting) {
setIsCasting(false);
if (chromecast.currentPosition > 0) {
seekToTime(chromecast.currentPosition);
}
if (wasPlayingBeforeCast) {
setPaused(false);
}
logger.log('[KSPlayerCore] Resumed local playback after Cast disconnect');
}
}, [chromecast.isCastConnected, isCasting, loadMediaToCast, chromecast.currentPosition, wasPlayingBeforeCast]);
// Sync Cast playback state
useEffect(() => {
if (isCasting && chromecast.isCastConnected) {
if (chromecast.currentPosition !== currentTime) {
setCurrentTime(chromecast.currentPosition);
}
if (chromecast.duration !== duration && chromecast.duration > 0) {
setDuration(chromecast.duration);
}
if (chromecast.isPlaying !== !paused) {
setPaused(!chromecast.isPlaying);
}
}
}, [isCasting, chromecast.isCastConnected, chromecast.currentPosition,
chromecast.duration, chromecast.isPlaying, currentTime, duration, paused]);
return (
<View style={[
styles.container,
@ -2487,6 +2595,34 @@ const KSPlayerCore: React.FC = () => {
<ActivityIndicator size="large" color="#E50914" />
</>
)}
{/* Cast Error Overlay */}
{chromecast.error && (
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.1,
alignSelf: 'center',
zIndex: 1000,
}}>
<View style={{
backgroundColor: 'rgba(255, 0, 0, 0.9)',
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 8,
maxWidth: 200,
}}>
<Text style={{ color: '#FFFFFF', fontSize: 12, textAlign: 'center' }}>
Cast Error: {chromecast.error}
</Text>
<TouchableOpacity onPress={chromecast.clearError}>
<Text style={{ color: '#FFFFFF', fontSize: 10, textAlign: 'center', textDecorationLine: 'underline' }}>
Dismiss
</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</Animated.View>
)}
@ -2696,6 +2832,9 @@ const KSPlayerCore: React.FC = () => {
isAirPlayActive={isAirPlayActive}
allowsAirPlay={allowsAirPlay}
onAirPlayPress={handleAirPlayPress}
isCastConnected={chromecast.isCastConnected}
castDevice={chromecast.castDevice?.name}
onCastPress={handleCastPress}
/>
{showPauseOverlay && (

View file

@ -0,0 +1,52 @@
import React from 'react';
import { Platform, ViewStyle, TouchableOpacity, View } from 'react-native';
import { CastButton } from 'react-native-google-cast';
import { MaterialIcons } from '@expo/vector-icons';
interface ChromecastButtonProps {
size?: number;
color?: string;
activeColor?: string;
style?: ViewStyle;
isConnected?: boolean;
onPress?: () => void;
}
export const ChromecastButton: React.FC<ChromecastButtonProps> = ({
size = 24,
color = 'white',
activeColor = '#E50914',
style,
isConnected = false,
onPress
}) => {
const currentColor = isConnected ? activeColor : color;
return (
<TouchableOpacity
style={[
{
padding: 8,
justifyContent: 'center',
alignItems: 'center',
},
style
]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={{ width: size, height: size }}>
<CastButton
style={{
width: size,
height: size,
tintColor: currentColor,
}}
/>
</View>
</TouchableOpacity>
);
};
export default ChromecastButton;

View file

@ -7,6 +7,7 @@ import Slider from '@react-native-community/slider';
import { styles } from '../utils/playerStyles'; // Updated styles
import { getTrackDisplayName } from '../utils/playerUtils';
import { useTheme } from '../../../contexts/ThemeContext';
import ChromecastButton from './ChromecastButton';
interface PlayerControlsProps {
showControls: boolean;
@ -49,6 +50,10 @@ interface PlayerControlsProps {
isAirPlayActive?: boolean;
allowsAirPlay?: boolean;
onAirPlayPress?: () => void;
// Chromecast props
isCastConnected?: boolean;
castDevice?: string;
onCastPress?: () => void;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
@ -90,6 +95,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
isAirPlayActive,
allowsAirPlay,
onAirPlayPress,
isCastConnected,
castDevice,
onCastPress,
}) => {
const { currentTheme } = useTheme();
@ -317,8 +325,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
)}
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{/* AirPlay Button - iOS only, KSAVPlayer only */}
{Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
{/* AirPlay Button - temporarily hidden */}
{false && Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
<TouchableOpacity
style={{ padding: 8 }}
onPress={onAirPlayPress}
@ -330,6 +338,17 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/>
</TouchableOpacity>
)}
{/* Chromecast Button - temporarily hidden */}
{false && onCastPress && (
<ChromecastButton
size={closeIconSize}
color="white"
activeColor="#E50914"
onPress={onCastPress}
isConnected={isCastConnected}
/>
)}
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={closeIconSize} color="white" />
</TouchableOpacity>

340
src/hooks/useChromecast.ts Normal file
View file

@ -0,0 +1,340 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import {
useRemoteMediaClient,
useCastSession,
CastContext,
PlayServicesState,
MediaPlayerState,
} from 'react-native-google-cast';
import { logger } from '../utils/logger';
export interface CastDevice {
id: string;
name: string;
isConnected: boolean;
}
export interface CastMediaInfo {
contentUrl: string;
contentType: string;
metadata?: {
title?: string;
subtitle?: string;
images?: Array<{ url: string }>;
studio?: string;
type?: 'movie' | 'series';
};
streamDuration?: number;
startTime?: number;
customData?: any;
}
export interface UseChromecastReturn {
// Connection state
isCastConnected: boolean;
castDevice: CastDevice | null;
isCastAvailable: boolean;
// Media control
loadMedia: (mediaInfo: CastMediaInfo) => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
seek: (position: number) => Promise<void>;
// Playback state from Cast device
currentPosition: number;
duration: number;
isPlaying: boolean;
// Device management
showCastPicker: () => void;
disconnect: () => Promise<void>;
// Error handling
error: string | null;
clearError: () => void;
}
export const useChromecast = (): UseChromecastReturn => {
// Cast SDK hooks
const client = useRemoteMediaClient();
const session = useCastSession();
// State
const [isCastConnected, setIsCastConnected] = useState(false);
const [castDevice, setCastDevice] = useState<CastDevice | null>(null);
const [isCastAvailable, setIsCastAvailable] = useState(false);
const [currentPosition, setCurrentPosition] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
// Refs for cleanup
const positionUpdateInterval = useRef<NodeJS.Timeout | null>(null);
const lastPositionRef = useRef(0);
// Check Cast availability
useEffect(() => {
// Cast is available on both iOS and Android
// The actual availability will be determined by the Cast SDK
setIsCastAvailable(true);
}, []);
// Monitor Cast session changes
useEffect(() => {
if (!session) {
setIsCastConnected(false);
setCastDevice(null);
return;
}
const updateDeviceInfo = async () => {
try {
const device = await session.getCastDevice();
if (device) {
const deviceInfo: CastDevice = {
id: device.deviceId,
name: device.friendlyName || device.deviceId,
isConnected: true
};
setIsCastConnected(true);
setCastDevice(deviceInfo);
logger.log('[useChromecast] Connected to Cast device:', deviceInfo.name);
}
} catch (error) {
logger.error('[useChromecast] Error getting device info:', error);
}
};
updateDeviceInfo();
return () => {
setIsCastConnected(false);
setCastDevice(null);
logger.log('[useChromecast] Disconnected from Cast device');
};
}, [session]);
// Monitor media status updates
useEffect(() => {
if (!client || !isCastConnected) {
setCurrentPosition(0);
setDuration(0);
setIsPlaying(false);
return;
}
const updateMediaStatus = async () => {
try {
const mediaStatus = await client.getMediaStatus();
if (mediaStatus) {
const playerState = mediaStatus.playerState;
const isPlaying = playerState === MediaPlayerState.PLAYING;
const isPaused = playerState === MediaPlayerState.PAUSED;
const position = (isPlaying || isPaused)
? (mediaStatus.streamPosition || 0)
: 0;
setCurrentPosition(position);
setDuration(mediaStatus.mediaInfo?.streamDuration || 0);
setIsPlaying(isPlaying);
// Update last position for comparison
lastPositionRef.current = position;
}
} catch (error) {
// Silently handle errors - media status might not be available yet
}
};
// Initial update
updateMediaStatus();
// Set up periodic updates (every 1 second)
positionUpdateInterval.current = setInterval(updateMediaStatus, 1000);
return () => {
if (positionUpdateInterval.current) {
clearInterval(positionUpdateInterval.current);
positionUpdateInterval.current = null;
}
};
}, [client, isCastConnected]);
// Load media to Cast device
const loadMedia = useCallback(async (mediaInfo: CastMediaInfo) => {
if (!client || !isCastConnected) {
setError('No Cast device connected');
return;
}
try {
setError(null);
const mediaLoadRequest = {
mediaInfo: {
contentUrl: mediaInfo.contentUrl,
contentType: mediaInfo.contentType,
streamDuration: mediaInfo.streamDuration,
customData: mediaInfo.customData
},
startTime: mediaInfo.startTime || 0,
autoplay: true
};
logger.log('[useChromecast] Loading media:', {
url: mediaInfo.contentUrl,
type: mediaInfo.contentType,
duration: mediaInfo.streamDuration,
startTime: mediaInfo.startTime
});
await client.loadMedia(mediaLoadRequest);
logger.log('[useChromecast] Media loaded successfully');
} catch (err) {
const errorMessage = `Failed to load media: ${err}`;
logger.error('[useChromecast] Error loading media:', err);
setError(errorMessage);
throw err;
}
}, [client, isCastConnected]);
// Play media
const play = useCallback(async () => {
if (!client || !isCastConnected) {
setError('No Cast device connected');
return;
}
try {
setError(null);
await client.play();
logger.log('[useChromecast] Play command sent');
} catch (err) {
const errorMessage = `Failed to play: ${err}`;
logger.error('[useChromecast] Error playing:', err);
setError(errorMessage);
throw err;
}
}, [client, isCastConnected]);
// Pause media
const pause = useCallback(async () => {
if (!client || !isCastConnected) {
setError('No Cast device connected');
return;
}
try {
setError(null);
await client.pause();
logger.log('[useChromecast] Pause command sent');
} catch (err) {
const errorMessage = `Failed to pause: ${err}`;
logger.error('[useChromecast] Error pausing:', err);
setError(errorMessage);
throw err;
}
}, [client, isCastConnected]);
// Seek to position
const seek = useCallback(async (position: number) => {
if (!client || !isCastConnected) {
setError('No Cast device connected');
return;
}
try {
setError(null);
await client.seek({ position });
logger.log('[useChromecast] Seek command sent to position:', position);
} catch (err) {
const errorMessage = `Failed to seek: ${err}`;
logger.error('[useChromecast] Error seeking:', err);
setError(errorMessage);
throw err;
}
}, [client, isCastConnected]);
// Show Cast device picker
const showCastPicker = useCallback(() => {
if (!isCastAvailable) {
setError('Chromecast not available');
return;
}
try {
setError(null);
// Use the CastContext directly - the library should handle this
CastContext.showCastDialog();
logger.log('[useChromecast] Cast dialog shown');
} catch (err) {
const errorMessage = `Failed to show Cast dialog: ${err}`;
logger.error('[useChromecast] Error showing Cast dialog:', err);
setError(errorMessage);
}
}, [isCastAvailable]);
// Disconnect from Cast device
const disconnect = useCallback(async () => {
if (!session) {
return;
}
try {
setError(null);
// For now, just log the disconnect attempt
// The actual disconnection will be handled by the Cast SDK
logger.log('[useChromecast] Disconnect requested');
} catch (err) {
const errorMessage = `Failed to disconnect: ${err}`;
logger.error('[useChromecast] Error disconnecting:', err);
setError(errorMessage);
throw err;
}
}, [session]);
// Clear error
const clearError = useCallback(() => {
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (positionUpdateInterval.current) {
clearInterval(positionUpdateInterval.current);
}
};
}, []);
return {
// Connection state
isCastConnected,
castDevice,
isCastAvailable,
// Media control
loadMedia,
play,
pause,
seek,
// Playback state
currentPosition,
duration,
isPlaying,
// Device management
showCastPicker,
disconnect,
// Error handling
error,
clearError
};
};

View file

@ -185,5 +185,7 @@ export const useMetadataAssets = (
foundTmdbId,
setBannerImage,
bannerSource,
logoLoadError: false,
setLogoLoadError: () => {},
};
};

View file

@ -74,13 +74,13 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
// The app will automatically reload with the new version
console.log('Update installed successfully');
} else {
toastService.showError('Installation Failed', 'Unable to install the update. Please try again later or check your internet connection.');
toastService.error('Installation Failed', 'Unable to install the update. Please try again later or check your internet connection.');
// Show popup again after failed installation
setShowUpdatePopup(true);
}
} catch (error) {
if (__DEV__) console.error('Error installing update:', error);
toastService.showError('Installation Error', 'An error occurred while installing the update. Please try again later.');
toastService.error('Installation Error', 'An error occurred while installing the update. Please try again later.');
// Show popup again after error
setShowUpdatePopup(true);
} finally {