mirror of
https://github.com/Jellify-Music/App.git
synced 2026-02-06 10:28:30 -06:00
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:
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function PlayerScreen(): React.JSX.Element {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setShowToast(true)
|
||||
|
||||
return () => setShowToast(false)
|
||||
}, []),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user