mirror of
https://github.com/Jellify-Music/App.git
synced 2025-12-30 15:29:49 -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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>C56D.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
42
src/zustand/engineStore.ts
Normal file
42
src/zustand/engineStore.ts
Normal 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
|
||||
10
yarn.lock
10
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==
|
||||
|
||||
Reference in New Issue
Block a user