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

@@ -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}>