From 94ba3af9d165e95d2ca623014c06fcbe4e7f0a3e Mon Sep 17 00:00:00 2001 From: Ritesh Shukla Date: Wed, 27 Aug 2025 03:06:57 +0530 Subject: [PATCH] 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. --- README.md | 8 +- android/.run/app.run.xml | 5 +- android/app/build.gradle | 20 +--- android/app/src/main/AndroidManifest.xml | 3 + .../src/main/java/com/jellify/MainActivity.kt | 12 +++ ios/AppDelegate.swift | 11 +++ ios/Jellify/Info.plist | 9 +- ios/Jellify/PrivacyInfo.xcprivacy | 1 + ios/Podfile.lock | 18 ++++ package.json | 6 +- src/components/Global/components/icon.tsx | 1 - src/components/Player/components/buttons.tsx | 6 +- src/components/Player/components/footer.tsx | 94 ++++++++++++++++++- src/components/Player/components/scrubber.tsx | 5 +- src/components/Player/index.tsx | 1 - src/components/Player/mini-player.tsx | 4 +- src/components/jellify.tsx | 2 + src/providers/Player/hooks/mutations.ts | 71 ++++++++++++-- src/providers/Player/hooks/queries.ts | 86 +++++++++++++++-- src/zustand/engineStore.ts | 42 +++++++++ yarn.lock | 10 ++ 21 files changed, 359 insertions(+), 56 deletions(-) create mode 100644 src/zustand/engineStore.ts 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 @@ - \ 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==