diff --git a/README.md b/README.md
index 41c47652..7ae290a3 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/android/.run/app.run.xml b/android/.run/app.run.xml
index c25e1059..59b94c8b 100644
--- a/android/.run/app.run.xml
+++ b/android/.run/app.run.xml
@@ -1,6 +1,5 @@
-
@@ -67,8 +66,6 @@
-
-
-
+
\ No newline at end of file
diff --git a/android/app/build.gradle b/android/app/build.gradle
index af5dad35..140bd9dd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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
- }
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b58c9106..ce109e38 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -36,5 +36,8 @@
+
\ No newline at end of file
diff --git a/android/app/src/main/java/com/jellify/MainActivity.kt b/android/app/src/main/java/com/jellify/MainActivity.kt
index 842b81f2..6bdec0c3 100644
--- a/android/app/src/main/java/com/jellify/MainActivity.kt
+++ b/android/app/src/main/java/com/jellify/MainActivity.kt
@@ -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)
+ }
}
diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift
index 518a8233..a4a1b62d 100644
--- a/ios/AppDelegate.swift
+++ b/ios/AppDelegate.swift
@@ -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,
diff --git a/ios/Jellify/Info.plist b/ios/Jellify/Info.plist
index a27331e1..aefb910b 100644
--- a/ios/Jellify/Info.plist
+++ b/ios/Jellify/Info.plist
@@ -58,10 +58,15 @@
+ NSBonjourServices
+
+ _googlecast._tcp
+ _CC1AD845._googlecast._tcp
+
NSLocalNetworkUsageDescription
${PRODUCT_NAME} uses the local network to connect to one's Jellyfin server for streaming music
NSLocationWhenInUseUsageDescription
-
+
RCTNewArchEnabled
UIAppFonts
@@ -136,4 +141,4 @@
UIViewControllerBasedStatusBarAppearance
-
\ No newline at end of file
+
diff --git a/ios/Jellify/PrivacyInfo.xcprivacy b/ios/Jellify/PrivacyInfo.xcprivacy
index a736257d..ffa95231 100644
--- a/ios/Jellify/PrivacyInfo.xcprivacy
+++ b/ios/Jellify/PrivacyInfo.xcprivacy
@@ -18,6 +18,7 @@
NSPrivacyAccessedAPITypeReasons
CA92.1
+ C56D.1
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index fd79efab..5bd55f79 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -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
diff --git a/package.json b/package.json
index a1e1c48e..4f54f6d5 100644
--- a/package.json
+++ b/package.json
@@ -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"
-}
\ No newline at end of file
+}
diff --git a/src/components/Global/components/icon.tsx b/src/components/Global/components/icon.tsx
index 49502c6e..777903fc 100644
--- a/src/components/Global/components/icon.tsx
+++ b/src/components/Global/components/icon.tsx
@@ -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}
diff --git a/src/components/Player/components/buttons.tsx b/src/components/Player/components/buttons.tsx
index 9679718c..75d6b198 100644
--- a/src/components/Player/components/buttons.tsx
+++ b/src/components/Player/components/buttons.tsx
@@ -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 = (
diff --git a/src/components/Player/components/footer.tsx b/src/components/Player/components/footer.tsx
index 919a8ddc..e7507678 100644
--- a/src/components/Player/components/footer.tsx
+++ b/src/components/Player/components/footer.tsx
@@ -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>()
+ 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 (
+
+
+
+
diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx
index ebd68cf0..9ca78c1d 100644
--- a/src/components/Player/components/scrubber.tsx
+++ b/src/components/Player/components/scrubber.tsx
@@ -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
diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx
index 7f41d9cd..b46eae41 100644
--- a/src/components/Player/index.tsx
+++ b/src/components/Player/index.tsx
@@ -26,7 +26,6 @@ export default function PlayerScreen(): React.JSX.Element {
useFocusEffect(
useCallback(() => {
setShowToast(true)
-
return () => setShowToast(false)
}, []),
)
diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx
index 184d5bfc..82c624ca 100644
--- a/src/components/Player/mini-player.tsx
+++ b/src/components/Player/mini-player.tsx
@@ -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,
diff --git a/src/components/jellify.tsx b/src/components/jellify.tsx
index 29c4b649..95a1c7e2 100644
--- a/src/components/jellify.tsx
+++ b/src/components/jellify.tsx
@@ -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 (
diff --git a/src/providers/Player/hooks/mutations.ts b/src/providers/Player/hooks/mutations.ts
index ad3d7d0f..461e5fe6 100644
--- a/src/providers/Player/hooks/mutations.ts
+++ b/src/providers/Player/hooks/mutations.ts
@@ -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>()
+
+ 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({
diff --git a/src/providers/Player/hooks/queries.ts b/src/providers/Player/hooks/queries.ts
index 39593814..4ff9ae2e 100644
--- a/src/providers/Player/hooks/queries.ts
+++ b/src/providers/Player/hooks/queries.ts
@@ -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)
+
+ useMemo(() => {
+ if (client && isCasting) {
+ client.onMediaStatusUpdated((status) => {
+ status?.playerState && setPlaybackState(castToRNTPState(status.playerState))
+ })
+ } else {
+ setPlaybackState(state)
+ }
+ }, [client, isCasting, state])
+
+ return playbackState
+}
diff --git a/src/zustand/engineStore.ts b/src/zustand/engineStore.ts
new file mode 100644
index 00000000..c15d0f91
--- /dev/null
+++ b/src/zustand/engineStore.ts
@@ -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()(
+ 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
diff --git a/yarn.lock b/yarn.lock
index e06746d2..a3d09519 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==