Adding Cast Support (#489)

Adds Google Cast Speaker Support!

A new button is now present in the bottom left of the player screen. Pressing this button will prompt the user to pick a Cast enabled speaker. Once a speaker is selected, the player controls in Jellify will control the playback of the Cast speaker.
This commit is contained in:
Ritesh Shukla
2025-08-27 03:06:57 +05:30
committed by GitHub
parent 170d146964
commit 94ba3af9d1
21 changed files with 359 additions and 56 deletions

View File

@@ -22,8 +22,6 @@
- [Features](#-features)
- [Built with](#-built-with-good-stuff)
- [Support](#-support-the-project)
- [Running Locally](#running-locally)
- [Contributing](#-contributing)
- [Special Thanks](#-special-thanks-to)
@@ -186,6 +184,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
- Powered by [react-native-ota-hot-update](https://github.com/vantuan88291/react-native-ota-hot-update), incremental app updates are automatically fetched and applied from our [App Bundles Repository](https://github.com/Jellify-Music/App-Bundles)
- Shuffling
- Switching Music Libraries
- Google Cast Support
### 🛠 Roadmap (in order of priority)
@@ -222,6 +221,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
[Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/react-native)\
[React Native DNS Lookup](https://github.com/tableau/react-native-dns-lookup)\
[React Native File Access](https://github.com/alpha0010/react-native-file-access)\
[React Native Google Cast](https://github.com/react-native-google-cast/react-native-google-cast)\
[React Native MMKV](https://github.com/mrousavy/react-native-mmkv)\
[React Native OTA Hot Update](https://github.com/vantuan88291/react-native-ota-hot-update)\
[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\
@@ -263,11 +263,13 @@ This allows me to prioritize specific features, acquire additional hardware for
- Extra thanks to [John](https://github.com/johngrantdev), [Vali-98](https://github.com/Vali-98), and [Erik](https://github.com/felinusfish) for shaping and designing the user experience
- Shout out to [skalthoff](https://github.com/skalthoff) for championing many features:
- Gapless Playback
- Library Selection
- Quality Selection
- Huge thank you to [Ritesh](https://github.com/riteshshukla04) for literally so many things:
- Offline Mode and Network Detection
- Error Boundary Detection
- Over-the-Air Updates
- _Supreme_ memes
- Cast Support
- The friends I made along the way that have been critical in fostering an amazing community around _Jellify_
- [Thalia](https://github.com/PercyGabriel1129)
- [BotBlake](https://github.com/BotBlake)

View File

@@ -1,6 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="Jellify.app" />
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
@@ -67,8 +66,6 @@
<option name="ACTIVITY_CLASS" value="" />
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
<method v="2" />
</configuration>
</component>

View File

@@ -64,18 +64,7 @@ react {
def enableProguardInReleaseBuilds = true
def enableSeparateBuildPerCPUArchitecture = true
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
def reactNativeArchitectures() {
@@ -131,10 +120,7 @@ android {
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation "com.google.android.gms:play-services-cast-framework:+"
implementation("com.facebook.react:hermes-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

View File

@@ -36,5 +36,8 @@
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.reactnative.googlecast.GoogleCastOptionsProvider" />
</application>
</manifest>

View File

@@ -5,6 +5,11 @@ import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.reactnative.googlecast.api.RNGCCastContext
import android.os.Bundle
import androidx.annotation.Nullable
class MainActivity : ReactActivity() {
@@ -20,5 +25,12 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(@Nullable savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// lazy load Google Cast context (if supported on this device)
RNGCCastContext.getSharedInstance(this)
}
}

View File

@@ -5,6 +5,8 @@ import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import react_native_ota_hot_update
import GoogleCast
@main
@@ -27,6 +29,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
window = UIWindow(frame: UIScreen.main.bounds)
let receiverAppID = kGCKDefaultMediaReceiverApplicationID // or "ABCD1234"
let criteria = GCKDiscoveryCriteria(applicationID: receiverAppID)
let options = GCKCastOptions(discoveryCriteria: criteria)
// Enable volume control with the physical buttons
options.physicalVolumeButtonsWillControlDeviceVolume = true
GCKCastContext.setSharedInstanceWith(options)
factory.startReactNative(
withModuleName: "Jellify",
in: window,

View File

@@ -58,10 +58,15 @@
</dict>
</dict>
</dict>
<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 connect to one's Jellyfin server for streaming music</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string/>
<string></string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
@@ -136,4 +141,4 @@
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
</plist>

View File

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

View File

@@ -7,6 +7,8 @@ PODS:
- FBLazyVector (0.81.0)
- fmt (11.0.2)
- glog (0.3.5)
- google-cast-sdk (4.8.3):
- Protobuf (~> 3.13)
- hermes-engine (0.81.0):
- hermes-engine/Pre-built (= 0.81.0)
- hermes-engine/Pre-built (0.81.0)
@@ -22,6 +24,8 @@ PODS:
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- PromisesObjC (2.4.0)
- Protobuf (3.29.5)
- RCT-Folly (2024.11.18.00):
- boost
- DoubleConversion
@@ -1797,6 +1801,10 @@ PODS:
- react-native-config/App (= 1.5.6)
- react-native-config/App (1.5.6):
- React-Core
- react-native-google-cast (4.9.1):
- google-cast-sdk
- PromisesObjC
- React
- react-native-mmkv (3.3.0):
- boost
- DoubleConversion
@@ -2954,6 +2962,7 @@ DEPENDENCIES:
- react-native-blurhash (from `../node_modules/react-native-blurhash`)
- react-native-carplay (from `../node_modules/react-native-carplay`)
- react-native-config (from `../node_modules/react-native-config`)
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- react-native-mmkv (from `../node_modules/react-native-mmkv`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-ota-hot-update (from `../node_modules/react-native-ota-hot-update`)
@@ -3009,7 +3018,10 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- google-cast-sdk
- libwebp
- PromisesObjC
- Protobuf
- SDWebImage
- SDWebImageWebPCoder
- Sentry
@@ -3111,6 +3123,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-carplay"
react-native-config:
:path: "../node_modules/react-native-config"
react-native-google-cast:
:path: "../node_modules/react-native-google-cast"
react-native-mmkv:
:path: "../node_modules/react-native-mmkv"
react-native-netinfo:
@@ -3220,8 +3234,11 @@ SPEC CHECKSUMS:
FBLazyVector: a867936a67af0d09c37935a1b900a1a3c795b6d1
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: e7491a2038f2618c8cd444ed411a6deb350a3742
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: 0735ab4f6b3ec93a7f98187b5da74d7916e2cf4c
RCTRequired: 8fcc7801bfc433072287b0f24a662e2816e89d0c
@@ -3260,6 +3277,7 @@ SPEC CHECKSUMS:
react-native-blurhash: c1721deafe7a685088ea14ab4712a1c460be9fe4
react-native-carplay: 8f388f6f73e5e0f73ed154ad8794371343ee20c0
react-native-config: f1dde39f8468ad922fc7e8bd4308c8e6223d5ee8
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-mmkv: 560d39188cf4d817fb34b0df79426a298934ee7d
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-ota-hot-update: 5c8fe703c7a789f6de651030e4740923c77fc610

View File

@@ -75,6 +75,7 @@
"react-native-flashdrag-list": "^0.2.5",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.0",
@@ -93,7 +94,8 @@
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "^1.132.22",
"use-context-selector": "^2.0.0"
"use-context-selector": "^2.0.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@babel/core": "^7.28.0",
@@ -142,4 +144,4 @@
"node": ">=18"
},
"packageManager": "yarn@1.22.22"
}
}

View File

@@ -49,7 +49,6 @@ export default function Icon({
onPress={onPress}
onPressIn={onPressIn}
hitSlop={getTokenValue('$2.5')}
marginHorizontal={'$1'}
width={size + getToken('$0.5')}
height={size + getToken('$0.5')}
flex={flex}

View File

@@ -1,8 +1,9 @@
import { State, usePlaybackState } from 'react-native-track-player'
import { State } from 'react-native-track-player'
import { Circle, Spinner, View } from 'tamagui'
import IconButton from '../../../components/Global/helpers/icon-button'
import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
export default function PlayPauseButton({
size,
@@ -13,10 +14,11 @@ export default function PlayPauseButton({
}): React.JSX.Element {
const { mutate: togglePlayback } = useTogglePlayback()
const { state } = usePlaybackState()
const state = usePlaybackState()
let button: React.JSX.Element = <></>
console.log('state', state)
switch (state) {
case State.Playing: {
button = (

View File

@@ -1,17 +1,107 @@
import { Spacer, XStack } from 'tamagui'
import { getToken, Spacer, useTheme, XStack } from 'tamagui'
import Icon from '../../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types'
import { useNavigation } from '@react-navigation/native'
import { PlayerParamList } from '../../../screens/Player/types'
import {
CastButton,
MediaHlsSegmentFormat,
useMediaStatus,
useRemoteMediaClient,
} from 'react-native-google-cast'
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useActiveTrack } from 'react-native-track-player'
import { useJellifyContext } from '../../../providers'
import { useEffect } from 'react'
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
export default function Footer(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
const playerEngineData = usePlayerEngineStore((state) => state.playerEngineData)
const theme = useTheme()
const remoteMediaClient = useRemoteMediaClient()
// const mediaStatus = useMediaStatus()
// console.log('mediaStatus', mediaStatus)
const { data: nowPlaying } = useNowPlaying()
function sanitizeJellyfinUrl(url: string): { url: string; extension: string | null } {
// Priority order for extensions
const priority = ['mp4', 'mp3', 'mov', 'm4a', '3gp']
// Extract base URL and query params
const [base, query] = url.split('?')
let sanitizedBase = base
let chosenExt: string | null = null
if (base.includes(',')) {
const parts = base.split('/')
const lastPart = parts.pop() || ''
const [streamBase, exts] = lastPart.split('stream.')
const extList = exts.split(',')
// Find best extension by priority
chosenExt = priority.find((ext) => extList.includes(ext)) || null
if (chosenExt) {
sanitizedBase = [...parts, `stream.${chosenExt}`].join('/')
}
} else {
// Handle single extension (no commas in base)
const match = base.match(/stream\.(\w+)$/)
chosenExt = match ? match[1] : null
}
// Update query params
const params = new URLSearchParams(query)
params.set('static', 'false')
return {
url: `${sanitizedBase}?${params.toString()}`,
extension: chosenExt,
}
}
const loadMediaToCast = async () => {
console.log('loadMediaToCast', remoteMediaClient, nowPlaying?.url, playerEngineData)
if (remoteMediaClient && nowPlaying?.url) {
const mediaStatus = await remoteMediaClient.getMediaStatus()
const sanitizedUrl = sanitizeJellyfinUrl(nowPlaying?.url)
if (mediaStatus?.mediaInfo?.contentUrl !== sanitizedUrl.url) {
remoteMediaClient.loadMedia({
mediaInfo: {
contentUrl: sanitizeJellyfinUrl(nowPlaying?.url).url,
contentType: `audio/${sanitizeJellyfinUrl(nowPlaying?.url).extension}`,
hlsSegmentFormat: MediaHlsSegmentFormat.MP3,
metadata: {
type: 'musicTrack',
title: nowPlaying?.title,
artist: nowPlaying?.artist,
albumTitle: nowPlaying?.album || '',
releaseDate: nowPlaying?.date || '',
images: [{ url: nowPlaying?.artwork || '' }],
},
},
})
}
}
}
useEffect(() => {
loadMediaToCast()
}, [remoteMediaClient, nowPlaying, playerEngineData])
return (
<XStack justifyContent='center' alignItems='center'>
<XStack alignItems='center' justifyContent='flex-start' flex={1}>
<CastButton style={{ tintColor: theme.color.val, width: 22, height: 22 }} />
</XStack>
<Spacer flex={1} />
<XStack alignItems='center' justifyContent='flex-end' flex={1}>

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { useProgress } from 'react-native-track-player'
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { trigger } from 'react-native-haptic-feedback'
@@ -10,7 +9,7 @@ import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../player/config'
import { ProgressMultiplier } from '../component.config'
import { useReducedHapticsContext } from '../../../providers/Settings'
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
// Create a simple pan gesture
const scrubGesture = Gesture.Pan().runOnJS(true)
@@ -39,7 +38,7 @@ export default function Scrubber(): React.JSX.Element {
}, [duration])
const calculatedPosition = useMemo(() => {
return Math.round(position * ProgressMultiplier)
return Math.round(position! * ProgressMultiplier)
}, [position])
// Optimized position update logic with throttling

View File

@@ -26,7 +26,6 @@ export default function PlayerScreen(): React.JSX.Element {
useFocusEffect(
useCallback(() => {
setShowToast(true)
return () => setShowToast(false)
}, []),
)

View File

@@ -8,7 +8,9 @@ import { ProgressMultiplier, TextTickerConfig } from './component.config'
import { useJellifyContext } from '../../providers'
import { RunTimeSeconds } from '../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../player/config'
import { useProgress, Progress as TrackPlayerProgress } from 'react-native-track-player'
import { Progress as TrackPlayerProgress } from 'react-native-track-player'
import { useProgress } from '../../providers/Player/hooks/queries'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
FadeIn,

View File

@@ -20,6 +20,7 @@ import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay'
import { useSelectPlayerEngine } from '../zustand/engineStore'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
@@ -28,6 +29,7 @@ export default function Jellify(): React.JSX.Element {
const theme = useThemeSettingContext()
const isDarkMode = useColorScheme() === 'dark'
useSelectPlayerEngine()
return (
<Theme name={theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme}>

View File

@@ -18,6 +18,12 @@ import {
import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from '../utils/normalization'
import { useNowPlaying, usePlaybackState } from './queries'
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
import { useRemoteMediaClient } from 'react-native-google-cast'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types'
import { useNavigation } from '@react-navigation/native'
const PLAYER_MUTATION_OPTIONS = {
retry: false,
@@ -62,21 +68,41 @@ export const usePlay = () =>
/**
* A mutation to handle toggling the playback state
*/
export const useTogglePlayback = () =>
useMutation({
export const useTogglePlayback = () => {
const state = usePlaybackState()
const isCasting =
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
return useMutation({
mutationFn: async () => {
trigger('impactMedium')
const { state } = await TrackPlayer.getPlaybackState()
if (state === State.Playing) {
console.debug('Pausing playback')
// handlePlaybackStateChanged(State.Paused)
return TrackPlayer.pause()
if (isCasting && remoteClient) {
remoteClient.pause()
return
} else {
TrackPlayer.pause()
return
}
}
const { duration, position } = await TrackPlayer.getProgress()
if (isCasting && remoteClient) {
const mediaStatus = await remoteClient.getMediaStatus()
const streamPosition = mediaStatus?.streamPosition
if (streamPosition && duration <= streamPosition) {
await remoteClient.seek({
position: 0,
resumeState: 'play',
})
}
await remoteClient.play()
return
}
// if the track has ended, seek to start and play
if (duration <= position) {
await TrackPlayer.seekTo(0)
@@ -86,6 +112,7 @@ export const useTogglePlayback = () =>
return TrackPlayer.play()
},
})
}
export const useToggleRepeatMode = () =>
useMutation({
@@ -110,13 +137,26 @@ export const useToggleRepeatMode = () =>
/**
* A mutation to handle seeking to a specific position in the track
*/
export const useSeekTo = () =>
useMutation({
export const useSeekTo = () => {
const isCasting =
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
return useMutation({
onMutate: () => trigger('impactLight'),
mutationFn: async (position: number) => {
console.log('position', position)
if (isCasting && remoteClient) {
await remoteClient.seek({
position: position,
resumeState: 'play',
})
return
}
await TrackPlayer.seekTo(position)
},
})
}
/**
* A mutation to handle seeking to a specific position in the track
@@ -163,8 +203,13 @@ export const useAddToQueue = () =>
onSettled: refetchPlayerQueue,
})
export const useLoadNewQueue = () =>
useMutation({
export const useLoadNewQueue = () => {
const isCasting =
usePlayerEngineStore((state) => state.playerEngineData) === PlayerEngine.GOOGLE_CAST
const remoteClient = useRemoteMediaClient()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
return useMutation({
onMutate: async () => {
trigger('impactLight')
await TrackPlayer.pause()
@@ -172,6 +217,11 @@ export const useLoadNewQueue = () =>
mutationFn: loadQueue,
onSuccess: async (finalStartIndex, { startPlayback }) => {
console.debug('Successfully loaded new queue')
if (isCasting && remoteClient) {
await TrackPlayer.skip(finalStartIndex)
navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
return
}
await TrackPlayer.skip(finalStartIndex)
@@ -183,6 +233,7 @@ export const useLoadNewQueue = () =>
},
onSettled: refetchPlayerQueue,
})
}
export const usePrevious = () =>
useMutation({

View File

@@ -1,6 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import PlayerQueryKeys from '../enums/queue-keys'
import TrackPlayer from 'react-native-track-player'
import TrackPlayer, {
Progress,
State,
useProgress as useProgressRNTP,
usePlaybackState as usePlaybackStateRNTP,
} from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { Queue } from '../../../player/types/queue-item'
import { SHUFFLED_QUERY_KEY } from '../constants/query-keys'
@@ -10,6 +15,15 @@ import {
QUEUE_QUERY,
REPEAT_MODE_QUERY,
} from '../constants/queries'
import usePlayerEngineStore from '../../../zustand/engineStore'
import { PlayerEngine } from '../../../zustand/engineStore'
import {
MediaPlayerState,
useMediaStatus,
useRemoteMediaClient,
useStreamPosition,
} from 'react-native-google-cast'
import { useEffect, useMemo, useState } from 'react'
const PLAYER_QUERY_OPTIONS = {
enabled: true,
@@ -21,13 +35,6 @@ const PLAYER_QUERY_OPTIONS = {
networkMode: 'always',
} as const
export const usePlaybackState = () =>
useQuery({
queryKey: [PlayerQueryKeys.PlaybackState],
queryFn: TrackPlayer.getPlaybackState,
...PLAYER_QUERY_OPTIONS,
})
export const useCurrentIndex = () => useQuery(CURRENT_INDEX_QUERY)
export const useNowPlaying = () => useQuery(NOW_PLAYING_QUERY)
@@ -52,3 +59,66 @@ export const useQueueRef = () =>
})
export const useRepeatMode = () => useQuery(REPEAT_MODE_QUERY)
export const useProgress = (UPDATE_INTERVAL: number): Progress => {
const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL)
const playerEngineData = usePlayerEngineStore((state) => state.playerEngineData)
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const streamPosition = useStreamPosition()
if (isCasting) {
return {
position: streamPosition || 0,
duration,
buffered: 0,
}
}
return {
position,
duration,
buffered,
}
}
const castToRNTPState = (state: MediaPlayerState): State => {
switch (state) {
case MediaPlayerState.PLAYING:
return State.Playing
case MediaPlayerState.PAUSED:
return State.Paused
case MediaPlayerState.BUFFERING:
return State.Buffering
case MediaPlayerState.IDLE:
return State.Ready
case MediaPlayerState.LOADING:
return State.Buffering
default:
return State.None
}
}
export const usePlaybackState = (): State | undefined => {
const { state } = usePlaybackStateRNTP()
console.log('state', state)
const playerEngineData = usePlayerEngineStore((state) => state.playerEngineData)
const client = useRemoteMediaClient()
const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST
const [playbackState, setPlaybackState] = useState<State | undefined>(state)
useMemo(() => {
if (client && isCasting) {
client.onMediaStatusUpdated((status) => {
status?.playerState && setPlaybackState(castToRNTPState(status.playerState))
})
} else {
setPlaybackState(state)
}
}, [client, isCasting, state])
return playbackState
}

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { useCastState, CastState } from 'react-native-google-cast'
import TrackPlayer from 'react-native-track-player'
export enum PlayerEngine {
GOOGLE_CAST = 'google_cast',
CARPLAY = 'carplay',
REACT_NATIVE_TRACK_PLAYER = 'react_native_track_player',
}
type playerEngineStore = {
playerEngineData: PlayerEngine
setPlayerEngineData: (data: PlayerEngine) => void
}
const usePlayerEngineStore = create<playerEngineStore>()(
devtools(
persist(
(set) => ({
playerEngineData: PlayerEngine.REACT_NATIVE_TRACK_PLAYER,
setPlayerEngineData: (data: PlayerEngine) => set({ playerEngineData: data }),
}),
{
name: 'player-engine-storage',
},
),
),
)
export const useSelectPlayerEngine = () => {
const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData)
const castState = useCastState()
if (castState === CastState.CONNECTED) {
setPlayerEngineData(PlayerEngine.GOOGLE_CAST)
TrackPlayer.pause() // pause the track player to avoid conflicts
return
}
setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER)
}
export default usePlayerEngineStore

View File

@@ -8477,6 +8477,11 @@ react-native-gesture-handler@^2.28.0:
hoist-non-react-statics "^3.3.0"
invariant "^2.2.4"
react-native-google-cast@^4.9.1:
version "4.9.1"
resolved "https://registry.yarnpkg.com/react-native-google-cast/-/react-native-google-cast-4.9.1.tgz#f1c453c11460a1f787accff80072492a4cd3e86c"
integrity sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ==
react-native-haptic-feedback@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/react-native-haptic-feedback/-/react-native-haptic-feedback-2.3.3.tgz#88b6876e91399a69bd1b551fe1681b2f3dc1214e"
@@ -10112,3 +10117,8 @@ yocto-queue@^1.0.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.1.tgz#36d7c4739f775b3cbc28e6136e21aa057adec418"
integrity sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==
zustand@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==