mirror of
https://github.com/Jellify-Music/App.git
synced 2026-05-18 19:08:37 -05:00
Add color presets with light/dark/OLED per preset (#956)
* These changes include adding an Unplayed filter and also fixing shuffle all to shuffle based on the filtered selection * Added genre filter and selection page * Forgot to hit save on this file * Fixed bug affecting genre filtering * Shuffle all query now includes the genres * Fixed trigger to triggerHaptic * Fixed trigger to triggerHaptic * Added optional color themes * Slight adjustment to some dark color presets --------- Co-authored-by: StephenArg <stephen@vody.com> Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
@@ -17,14 +17,14 @@ import TrackPlayer, {
|
||||
import { CAPABILITIES } from './src/constants/player'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { NavigationContainer } from '@react-navigation/native'
|
||||
import { JellifyDarkTheme, JellifyLightTheme, JellifyOLEDTheme } from './src/components/theme'
|
||||
import { getJellifyNavTheme } from './src/components/theme'
|
||||
import { requestStoragePermission } from './src/utils/permisson-helpers'
|
||||
import ErrorBoundary from './src/components/ErrorBoundary'
|
||||
import OTAUpdateScreen from './src/components/OtaUpdates'
|
||||
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
|
||||
import navigationRef from './navigation'
|
||||
import { BUFFERS, PROGRESS_UPDATE_EVENT_INTERVAL } from './src/configs/player.config'
|
||||
import { useThemeSetting } from './src/stores/settings/app'
|
||||
import { useColorPresetSetting, useThemeSetting } from './src/stores/settings/app'
|
||||
import { getApi } from './src/stores'
|
||||
import CarPlayNavigation from './src/components/CarPlay/Navigation'
|
||||
import { CarPlay } from 'react-native-carplay'
|
||||
@@ -124,23 +124,15 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Element {
|
||||
const [theme] = useThemeSetting()
|
||||
const [colorPreset] = useColorPresetSetting()
|
||||
|
||||
const isDarkMode = useColorScheme() === 'dark'
|
||||
const resolvedMode = theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={
|
||||
theme === 'system'
|
||||
? isDarkMode
|
||||
? JellifyDarkTheme
|
||||
: JellifyLightTheme
|
||||
: theme === 'dark'
|
||||
? JellifyDarkTheme
|
||||
: theme === 'oled'
|
||||
? JellifyOLEDTheme
|
||||
: JellifyLightTheme
|
||||
}
|
||||
theme={getJellifyNavTheme(colorPreset, resolvedMode)}
|
||||
>
|
||||
<GestureHandlerRootView>
|
||||
<TamaguiProvider config={jellifyConfig}>
|
||||
|
||||
@@ -64,6 +64,16 @@ export default function Icon({
|
||||
|
||||
const pressStyle = animation ? { opacity: 0.6 } : undefined
|
||||
|
||||
// Tamagui theme keys are unprefixed (e.g. "primary" not "$primary"); resolve for token strings
|
||||
const themeColorKey =
|
||||
color && typeof color === 'string' && color.startsWith('$') ? color.slice(1) : color
|
||||
const resolvedColor =
|
||||
color && !disabled
|
||||
? (theme[themeColorKey as keyof typeof theme]?.val ?? theme.color.val)
|
||||
: disabled
|
||||
? theme.neutral.val
|
||||
: theme.color.val
|
||||
|
||||
return (
|
||||
<YStack
|
||||
animation={animation}
|
||||
@@ -76,13 +86,7 @@ export default function Icon({
|
||||
flex={flex}
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
color={
|
||||
color && !disabled
|
||||
? theme[color]?.val
|
||||
: disabled
|
||||
? theme.neutral.val
|
||||
: theme.color.val
|
||||
}
|
||||
color={resolvedColor}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
name={name as any}
|
||||
size={size}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SizeTokens, XStack, Separator, Switch, styled, getToken } from 'tamagui'
|
||||
import { SizeTokens, XStack, Separator, Switch, styled } from 'tamagui'
|
||||
import { Label } from './text'
|
||||
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
|
||||
|
||||
@@ -10,9 +10,10 @@ interface SwitchWithLabelProps {
|
||||
width?: number | undefined
|
||||
}
|
||||
|
||||
// Use theme tokens so thumb colors follow the active color preset
|
||||
const JellifySliderThumb = styled(Switch.Thumb, {
|
||||
borderColor: getToken('$color.amethyst'),
|
||||
backgroundColor: getToken('$color.purpleDark'),
|
||||
borderColor: '$primary',
|
||||
backgroundColor: '$background',
|
||||
})
|
||||
|
||||
export function SwitchWithLabel(props: SwitchWithLabelProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { getToken, useTheme, View, YStack, ZStack } from 'tamagui'
|
||||
import { useTheme, View, YStack, ZStack } from 'tamagui'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import { getBlurhashFromDto } from '../../../utils/parsing/blurhash'
|
||||
@@ -18,13 +18,13 @@ export default function BlurredBackground(): React.JSX.Element {
|
||||
// Get blurhash safely
|
||||
const blurhash = nowPlaying?.item ? getBlurhashFromDto(nowPlaying.item) : null
|
||||
|
||||
// Define gradient colors
|
||||
const darkGradientColors = [getToken('$black'), getToken('$black25')]
|
||||
// Use theme colors so the gradient follows the active color preset
|
||||
const darkGradientColors = [theme.background.val, theme.background25.val]
|
||||
const darkGradientColors2 = [
|
||||
getToken('$black25'),
|
||||
getToken('$black75'),
|
||||
getToken('$black'),
|
||||
getToken('$black'),
|
||||
theme.background25.val,
|
||||
theme.background75.val,
|
||||
theme.background.val,
|
||||
theme.background.val,
|
||||
]
|
||||
|
||||
// Define styles
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Progress, XStack, YStack } from 'tamagui'
|
||||
import { Progress, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import TextTicker from 'react-native-text-ticker'
|
||||
@@ -30,6 +30,7 @@ export default function Miniplayer(): React.JSX.Element {
|
||||
const nowPlaying = useCurrentTrack()
|
||||
const skip = useSkip()
|
||||
const previous = usePrevious()
|
||||
const theme = useTheme()
|
||||
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
|
||||
|
||||
@@ -90,7 +91,7 @@ export default function Miniplayer(): React.JSX.Element {
|
||||
pressStyle={pressStyle}
|
||||
animation={'quick'}
|
||||
onPress={openPlayer}
|
||||
backgroundColor='$background'
|
||||
backgroundColor={theme.background.val}
|
||||
>
|
||||
<MiniPlayerProgress />
|
||||
<XStack alignItems='center' padding={'$2'}>
|
||||
@@ -144,15 +145,19 @@ export default function Miniplayer(): React.JSX.Element {
|
||||
|
||||
function MiniPlayerProgress(): React.JSX.Element {
|
||||
const progress = useProgress(UPDATE_INTERVAL)
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<Progress
|
||||
height={'$0.25'}
|
||||
value={calculateProgressPercentage(progress)}
|
||||
backgroundColor={'$borderColor'}
|
||||
backgroundColor={theme.borderColor.val}
|
||||
borderBottomEndRadius={'$2'}
|
||||
>
|
||||
<Progress.Indicator borderColor={'$primary'} backgroundColor={'$primary'} />
|
||||
<Progress.Indicator
|
||||
borderColor={theme.primary.val}
|
||||
backgroundColor={theme.primary.val}
|
||||
/>
|
||||
</Progress>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
|
||||
import { getToken, getTokenValue, useTheme, Spinner, YStack } from 'tamagui'
|
||||
import { getTokenValue, useTheme, Spinner, YStack } from 'tamagui'
|
||||
import { useColorPresetSetting, useThemeSetting } from '../../stores/settings/app'
|
||||
import SettingsTabBar from './tab-bar'
|
||||
|
||||
// Lazy load tab components to improve initial render
|
||||
@@ -63,9 +65,16 @@ function LazyInfoTab() {
|
||||
|
||||
export default function Settings(): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const [themeSetting] = useThemeSetting()
|
||||
const [colorPreset] = useColorPresetSetting()
|
||||
const isDarkMode = useColorScheme() === 'dark'
|
||||
const resolvedMode = themeSetting === 'system' ? (isDarkMode ? 'dark' : 'light') : themeSetting
|
||||
// Key forces navigator to remount when preset/mode changes so tab bar colors update
|
||||
const themeKey = `${colorPreset}_${resolvedMode}`
|
||||
|
||||
return (
|
||||
<SettingsTabsNavigator.Navigator
|
||||
key={themeKey}
|
||||
screenOptions={{
|
||||
tabBarIndicatorStyle: {
|
||||
borderColor: theme.background.val,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { YStack, XStack, Paragraph, SizableText } from 'tamagui'
|
||||
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
|
||||
import SettingsListGroup from './settings-list-group'
|
||||
import {
|
||||
ColorPreset,
|
||||
ThemeSetting,
|
||||
useColorPresetSetting,
|
||||
useHideRunTimesSetting,
|
||||
useReducedHapticsSetting,
|
||||
useSendMetricsSetting,
|
||||
@@ -18,6 +20,12 @@ type ThemeOptionConfig = {
|
||||
icon: string
|
||||
}
|
||||
|
||||
type ColorPresetOptionConfig = {
|
||||
value: ColorPreset
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: ThemeOptionConfig[] = [
|
||||
{
|
||||
value: 'system',
|
||||
@@ -41,6 +49,34 @@ const THEME_OPTIONS: ThemeOptionConfig[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const COLOR_PRESET_OPTIONS: ColorPresetOptionConfig[] = [
|
||||
{
|
||||
value: 'purple',
|
||||
label: 'Purple',
|
||||
icon: 'crown',
|
||||
},
|
||||
{
|
||||
value: 'ocean',
|
||||
label: 'Ocean',
|
||||
icon: 'waves',
|
||||
},
|
||||
{
|
||||
value: 'forest',
|
||||
label: 'Forest',
|
||||
icon: 'forest',
|
||||
},
|
||||
{
|
||||
value: 'sunset',
|
||||
label: 'Sunset',
|
||||
icon: 'weather-sunset',
|
||||
},
|
||||
{
|
||||
value: 'peanut',
|
||||
label: 'Peanut',
|
||||
icon: 'peanut',
|
||||
},
|
||||
]
|
||||
|
||||
function ActionChip({
|
||||
active,
|
||||
label,
|
||||
@@ -113,6 +149,42 @@ function ThemeOptionCard({
|
||||
)
|
||||
}
|
||||
|
||||
function ColorPresetOptionCard({
|
||||
option,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
option: ColorPresetOptionConfig
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPress}
|
||||
pressStyle={{ scale: 0.97 }}
|
||||
animation='quick'
|
||||
borderWidth={'$1'}
|
||||
borderColor={isSelected ? '$primary' : '$borderColor'}
|
||||
backgroundColor={isSelected ? '$background25' : '$background'}
|
||||
borderRadius={'$9'}
|
||||
padding='$3'
|
||||
gap='$2'
|
||||
hitSlop={8}
|
||||
role='button'
|
||||
aria-label={`${option.label} color preset option`}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<XStack alignItems='center' gap='$2'>
|
||||
<Icon small name={option.icon} color={isSelected ? '$primary' : '$borderColor'} />
|
||||
<SizableText size={'$4'} flex={1} fontWeight='600'>
|
||||
{option.label}
|
||||
</SizableText>
|
||||
{isSelected && <Icon small name='check-circle-outline' color={'$primary'} />}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function getThemeSubtitle(themeSetting: ThemeSetting): string {
|
||||
switch (themeSetting) {
|
||||
case 'light':
|
||||
@@ -126,10 +198,28 @@ function getThemeSubtitle(themeSetting: ThemeSetting): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getColorPresetSubtitle(colorPreset: ColorPreset): string {
|
||||
switch (colorPreset) {
|
||||
case 'purple':
|
||||
return 'Purple vibes'
|
||||
case 'ocean':
|
||||
return 'Oceanic vibes'
|
||||
case 'forest':
|
||||
return 'Foresty vibes'
|
||||
case 'sunset':
|
||||
return 'Sunset vibes'
|
||||
case 'peanut':
|
||||
return 'Sandbox vibes'
|
||||
default:
|
||||
return 'Default vibes'
|
||||
}
|
||||
}
|
||||
|
||||
export default function PreferencesTab(): React.JSX.Element {
|
||||
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
|
||||
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
|
||||
const [themeSetting, setThemeSetting] = useThemeSetting()
|
||||
const [colorPreset, setColorPreset] = useColorPresetSetting()
|
||||
const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting()
|
||||
|
||||
const left = useSwipeSettingsStore((s) => s.left)
|
||||
@@ -138,7 +228,7 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
const toggleRight = useSwipeSettingsStore((s) => s.toggleRight)
|
||||
|
||||
const themeSubtitle = getThemeSubtitle(themeSetting)
|
||||
|
||||
const colorPresetSubtitle = getColorPresetSubtitle(colorPreset)
|
||||
return (
|
||||
<SettingsListGroup
|
||||
settingsList={[
|
||||
@@ -160,6 +250,24 @@ export default function PreferencesTab(): React.JSX.Element {
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Color Preset',
|
||||
subTitle: colorPresetSubtitle && `${colorPresetSubtitle}`,
|
||||
iconName: 'palette',
|
||||
iconColor: '$primary',
|
||||
children: (
|
||||
<YStack gap='$2' paddingVertical='$2'>
|
||||
{COLOR_PRESET_OPTIONS.map((option) => (
|
||||
<ColorPresetOptionCard
|
||||
key={option.value}
|
||||
option={option}
|
||||
isSelected={colorPreset === option.value}
|
||||
onPress={() => setColorPreset(option.value)}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Track Swipe Actions',
|
||||
subTitle: 'Choose actions for left/right swipes',
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
} from '@typedigital/telemetrydeck-react'
|
||||
import telemetryDeckConfig from '../../telemetrydeck.json'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import { getToken, Theme, useTheme } from 'tamagui'
|
||||
import { getToken, Theme, ThemeName, useTheme } from 'tamagui'
|
||||
import Toast from 'react-native-toast-message'
|
||||
import JellifyToastConfig from '../configs/toast.config'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import { StorageProvider } from '../providers/Storage'
|
||||
import { useSelectPlayerEngine } from '../stores/player/engine'
|
||||
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
|
||||
import {
|
||||
useColorPresetSetting,
|
||||
useSendMetricsSetting,
|
||||
useThemeSetting,
|
||||
} from '../stores/settings/app'
|
||||
import { GLITCHTIP_DSN } from '../configs/config'
|
||||
import useDownloadProcessor from '../hooks/use-download-processor'
|
||||
/**
|
||||
@@ -25,12 +29,17 @@ import useDownloadProcessor from '../hooks/use-download-processor'
|
||||
*/
|
||||
export default function Jellify(): React.JSX.Element {
|
||||
const [theme] = useThemeSetting()
|
||||
const [colorPreset] = useColorPresetSetting()
|
||||
|
||||
const isDarkMode = useColorScheme() === 'dark'
|
||||
|
||||
const resolvedMode = theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme
|
||||
const themeName = `${colorPreset}_${resolvedMode}` // e.g. 'purple_dark'
|
||||
|
||||
useSelectPlayerEngine()
|
||||
|
||||
return (
|
||||
<Theme name={theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme}>
|
||||
<Theme name={themeName as ThemeName | null}>
|
||||
<JellifyLoggingWrapper>
|
||||
<DisplayProvider>
|
||||
<App />
|
||||
|
||||
+32
-32
@@ -1,5 +1,7 @@
|
||||
import { DarkTheme, DefaultTheme } from '@react-navigation/native'
|
||||
import { getToken, getTokens } from 'tamagui'
|
||||
import type { Theme } from '@react-navigation/native'
|
||||
import { PRESET_PALETTES } from '../../tamagui.config'
|
||||
import type { ColorPreset } from '../stores/settings/app'
|
||||
|
||||
interface Fonts {
|
||||
regular: FontStyle
|
||||
@@ -43,38 +45,36 @@ const JellifyFonts: Fonts = {
|
||||
},
|
||||
}
|
||||
|
||||
export const JellifyDarkTheme: ReactNavigation.Theme = {
|
||||
dark: true,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
card: getTokens().color.$darkBackground.val,
|
||||
border: getTokens().color.$neutral.val,
|
||||
background: getTokens().color.$darkBackground.val,
|
||||
primary: getTokens().color.$primaryDark.val,
|
||||
},
|
||||
fonts: JellifyFonts,
|
||||
function paletteToNavTheme(
|
||||
palette: (typeof PRESET_PALETTES)['purple']['dark'],
|
||||
dark: boolean,
|
||||
): Theme {
|
||||
const base = dark ? DarkTheme : DefaultTheme
|
||||
return {
|
||||
...base,
|
||||
dark,
|
||||
colors: {
|
||||
...base.colors,
|
||||
background: palette.background,
|
||||
card: palette.background,
|
||||
border: palette.borderColor,
|
||||
primary: palette.primary,
|
||||
},
|
||||
fonts: JellifyFonts,
|
||||
}
|
||||
}
|
||||
|
||||
export const JellifyLightTheme = {
|
||||
...DefaultTheme,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
primary: getTokens().color.$primaryLight.val,
|
||||
border: getTokens().color.$neutral.val,
|
||||
background: getTokens().color.$white.val,
|
||||
card: getTokens().color.$white.val,
|
||||
},
|
||||
fonts: JellifyFonts,
|
||||
/** React Navigation theme for a given color preset and mode (purple_dark, ocean_light, etc.) */
|
||||
export function getJellifyNavTheme(preset: ColorPreset, mode: 'light' | 'dark' | 'oled'): Theme {
|
||||
const palette = PRESET_PALETTES[preset][mode]
|
||||
return paletteToNavTheme(palette, mode !== 'light')
|
||||
}
|
||||
|
||||
export const JellifyOLEDTheme: ReactNavigation.Theme = {
|
||||
dark: true,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
card: getTokens().color.$black.val,
|
||||
border: getTokens().color.$neutral.val,
|
||||
background: getTokens().color.$black.val,
|
||||
primary: getTokens().color.$primaryDark.val,
|
||||
},
|
||||
fonts: JellifyFonts,
|
||||
}
|
||||
/** Purple dark — matches Tamagui purple_dark (current JellifyDarkTheme) */
|
||||
export const JellifyDarkTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.dark, true)
|
||||
|
||||
/** Purple light — matches Tamagui purple_light (current JellifyLightTheme) */
|
||||
export const JellifyLightTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.light, false)
|
||||
|
||||
/** Purple oled — matches Tamagui purple_oled (current JellifyOLEDTheme) */
|
||||
export const JellifyOLEDTheme: Theme = paletteToNavTheme(PRESET_PALETTES.purple.oled, true)
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function Tabs({ route, navigation }: TabProps): React.JSX.Element
|
||||
animation: 'shift',
|
||||
tabBarActiveTintColor: theme.primary.val,
|
||||
tabBarInactiveTintColor: theme.borderColor.val,
|
||||
tabBarStyle: { backgroundColor: theme.background.val },
|
||||
lazy: true,
|
||||
}}
|
||||
tabBar={(props) => <TabBar {...props} />}
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import Miniplayer from '../../components/Player/mini-player'
|
||||
import InternetConnectionWatcher from '../../components/Network/internetConnectionWatcher'
|
||||
import { BottomTabBar, BottomTabBarProps } from '@react-navigation/bottom-tabs'
|
||||
import useIsMiniPlayerActive from '../../hooks/use-mini-player'
|
||||
import { useTheme } from 'tamagui'
|
||||
|
||||
export default function TabBar({ ...props }: BottomTabBarProps): React.JSX.Element {
|
||||
/**
|
||||
* Merge theme-driven tab bar options into the focused route's descriptor
|
||||
* so the bar updates immediately when color preset changes (the navigator
|
||||
* often does not re-pass updated screenOptions to the tab bar).
|
||||
*/
|
||||
export default function TabBar(props: BottomTabBarProps): React.JSX.Element {
|
||||
const isMiniPlayerActive = useIsMiniPlayerActive()
|
||||
const theme = useTheme()
|
||||
|
||||
const descriptorsWithTheme = useMemo(() => {
|
||||
const focusedRoute = props.state.routes[props.state.index]
|
||||
const focusedDescriptor = props.descriptors[focusedRoute.key]
|
||||
if (!focusedDescriptor) return props.descriptors
|
||||
return {
|
||||
...props.descriptors,
|
||||
[focusedRoute.key]: {
|
||||
...focusedDescriptor,
|
||||
options: {
|
||||
...focusedDescriptor.options,
|
||||
tabBarStyle: {
|
||||
...focusedDescriptor.options.tabBarStyle,
|
||||
backgroundColor: theme.background.val,
|
||||
},
|
||||
tabBarActiveTintColor: theme.primary.val,
|
||||
tabBarInactiveTintColor: theme.borderColor.val,
|
||||
},
|
||||
},
|
||||
}
|
||||
}, [
|
||||
props.descriptors,
|
||||
props.state.routes,
|
||||
props.state.index,
|
||||
theme.background.val,
|
||||
theme.primary.val,
|
||||
theme.borderColor.val,
|
||||
])
|
||||
|
||||
// Key forces mini-player to remount when theme changes so colors update
|
||||
// (avoids stale styles from Reanimated/Progress when preset changes without interaction)
|
||||
const themeKey = `${theme.background.val}-${theme.primary.val}`
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMiniPlayerActive && <Miniplayer />}
|
||||
{isMiniPlayerActive && <Miniplayer key={themeKey} />}
|
||||
<InternetConnectionWatcher />
|
||||
|
||||
<BottomTabBar {...props} />
|
||||
<BottomTabBar {...props} descriptors={descriptorsWithTheme} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
export type ThemeSetting = 'system' | 'light' | 'dark' | 'oled'
|
||||
export type ColorPreset = 'purple' | 'ocean' | 'forest' | 'sunset' | 'peanut'
|
||||
|
||||
type AppSettingsStore = {
|
||||
sendMetrics: boolean
|
||||
@@ -17,6 +18,9 @@ type AppSettingsStore = {
|
||||
|
||||
theme: ThemeSetting
|
||||
setTheme: (theme: ThemeSetting) => void
|
||||
|
||||
colorPreset: ColorPreset
|
||||
setColorPreset: (colorPreset: ColorPreset) => void
|
||||
}
|
||||
|
||||
export const useAppSettingsStore = create<AppSettingsStore>()(
|
||||
@@ -34,6 +38,9 @@ export const useAppSettingsStore = create<AppSettingsStore>()(
|
||||
|
||||
theme: 'system',
|
||||
setTheme: (theme: ThemeSetting) => set({ theme }),
|
||||
|
||||
colorPreset: 'purple',
|
||||
setColorPreset: (colorPreset: ColorPreset) => set({ colorPreset }),
|
||||
}),
|
||||
{
|
||||
name: 'app-settings-storage',
|
||||
@@ -50,6 +57,18 @@ export const useThemeSetting: () => [ThemeSetting, (theme: ThemeSetting) => void
|
||||
|
||||
return [theme, setTheme]
|
||||
}
|
||||
|
||||
export const useColorPresetSetting: () => [
|
||||
ColorPreset,
|
||||
(colorPreset: ColorPreset) => void,
|
||||
] = () => {
|
||||
const colorPreset = useAppSettingsStore((state) => state.colorPreset)
|
||||
|
||||
const setColorPreset = useAppSettingsStore((state) => state.setColorPreset)
|
||||
|
||||
return [colorPreset, setColorPreset]
|
||||
}
|
||||
|
||||
export const useReducedHapticsSetting: () => [boolean, (reducedHaptics: boolean) => void] = () => {
|
||||
const reducedHaptics = useAppSettingsStore((state) => state.reducedHaptics)
|
||||
|
||||
|
||||
+270
-83
@@ -51,6 +51,275 @@ const tokens = createTokens({
|
||||
},
|
||||
})
|
||||
|
||||
/** Theme mode palette: semantic keys used by Tamagui and React Navigation */
|
||||
type PresetModePalette = {
|
||||
background: string
|
||||
background75: string
|
||||
background50: string
|
||||
background25: string
|
||||
borderColor: string
|
||||
color: string
|
||||
success: string
|
||||
secondary: string
|
||||
primary: string
|
||||
danger: string
|
||||
warning: string
|
||||
neutral: string
|
||||
translucent: string
|
||||
}
|
||||
|
||||
/** Palettes per preset (purple = current Jellify themes), for Tamagui + nav */
|
||||
export const PRESET_PALETTES: Record<
|
||||
'purple' | 'ocean' | 'forest' | 'sunset' | 'peanut',
|
||||
{ light: PresetModePalette; dark: PresetModePalette; oled: PresetModePalette }
|
||||
> = {
|
||||
purple: {
|
||||
// Matches current JellifyDarkTheme / JellifyLightTheme / JellifyOLEDTheme
|
||||
dark: {
|
||||
background: 'rgba(25, 24, 28, 1)',
|
||||
background75: 'rgba(25, 24, 28, 0.75)',
|
||||
background50: 'rgba(25, 24, 28, 0.5)',
|
||||
background25: 'rgba(25, 24, 28, 0.25)',
|
||||
borderColor: '#77748E',
|
||||
color: '#ffffff',
|
||||
success: 'rgba(87, 233, 201, 1)',
|
||||
secondary: 'rgba(75, 125, 215, 1)',
|
||||
primary: '#887BFF',
|
||||
danger: '#FF066F',
|
||||
warning: '#FF6625',
|
||||
neutral: '#77748E',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
light: {
|
||||
background: '#ffffff',
|
||||
background75: 'rgba(235, 221, 255, 0.75)',
|
||||
background50: 'rgba(235, 221, 255, 0.5)',
|
||||
background25: 'rgba(235, 221, 255, 0.25)',
|
||||
borderColor: '#77748E',
|
||||
color: '#0C0622',
|
||||
success: 'rgba(16, 175, 141, 1)',
|
||||
secondary: 'rgba(0, 58, 159, 1)',
|
||||
primary: '#4b0fd6ff',
|
||||
danger: '#B30077',
|
||||
warning: '#a93300ff',
|
||||
neutral: '#77748E',
|
||||
translucent: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
oled: {
|
||||
background: '#000000',
|
||||
background75: 'rgba(0, 0, 0, 0.75)',
|
||||
background50: 'rgba(0, 0, 0, 0.5)',
|
||||
background25: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: '#77748E',
|
||||
color: '#ffffff',
|
||||
success: 'rgba(87, 233, 201, 1)',
|
||||
secondary: 'rgba(75, 125, 215, 1)',
|
||||
primary: '#887BFF',
|
||||
danger: '#FF066F',
|
||||
warning: '#FF6625',
|
||||
neutral: '#77748E',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
ocean: {
|
||||
dark: {
|
||||
background: 'rgba(25, 24, 28, 1)',
|
||||
background75: 'rgba(25, 24, 28, 0.75)',
|
||||
background50: 'rgba(25, 24, 28, 0.5)',
|
||||
background25: 'rgba(25, 24, 28, 0.25)',
|
||||
borderColor: '#78909C',
|
||||
color: '#ffffff',
|
||||
success: '#4DD0E1',
|
||||
secondary: '#81D4FA',
|
||||
primary: '#4FC3F7',
|
||||
danger: '#FF7043',
|
||||
warning: '#FFB74D',
|
||||
neutral: '#78909C',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
light: {
|
||||
background: '#E1F5FE',
|
||||
background75: 'rgba(225, 245, 254, 0.75)',
|
||||
background50: 'rgba(225, 245, 254, 0.5)',
|
||||
background25: 'rgba(225, 245, 254, 0.25)',
|
||||
borderColor: '#546E7A',
|
||||
color: '#01579B',
|
||||
success: '#00838F',
|
||||
secondary: '#0277BD',
|
||||
primary: '#0288D1',
|
||||
danger: '#D84315',
|
||||
warning: '#EF6C00',
|
||||
neutral: '#546E7A',
|
||||
translucent: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
oled: {
|
||||
background: '#000000',
|
||||
background75: 'rgba(0, 0, 0, 0.75)',
|
||||
background50: 'rgba(0, 0, 0, 0.5)',
|
||||
background25: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: '#78909C',
|
||||
color: '#ffffff',
|
||||
success: '#4DD0E1',
|
||||
secondary: '#81D4FA',
|
||||
primary: '#4FC3F7',
|
||||
danger: '#FF7043',
|
||||
warning: '#FFB74D',
|
||||
neutral: '#78909C',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
forest: {
|
||||
dark: {
|
||||
background: 'rgb(35, 47, 35)',
|
||||
background75: 'rgba(35, 47, 35, 0.75)',
|
||||
background50: 'rgba(35, 47, 35, 0.5)',
|
||||
background25: 'rgba(35, 47, 35, 0.25)',
|
||||
borderColor: '#8D9E8C',
|
||||
color: '#ffffff',
|
||||
success: '#66BB6A',
|
||||
secondary: '#9CCC65',
|
||||
primary: 'rgb(56, 105, 56)',
|
||||
danger: '#E57373',
|
||||
warning: '#FFB74D',
|
||||
neutral: '#8D9E8C',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
light: {
|
||||
background: '#E8F5E9',
|
||||
background75: 'rgba(232, 245, 233, 0.75)',
|
||||
background50: 'rgba(232, 245, 233, 0.5)',
|
||||
background25: 'rgba(232, 245, 233, 0.25)',
|
||||
borderColor: '#558B2F',
|
||||
color: '#1B5E20',
|
||||
success: '#2E7D32',
|
||||
secondary: '#43A047',
|
||||
primary: 'rgb(14, 143, 21)',
|
||||
danger: '#C62828',
|
||||
warning: '#E65100',
|
||||
neutral: '#558B2F',
|
||||
translucent: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
oled: {
|
||||
background: '#000000',
|
||||
background75: 'rgba(0, 0, 0, 0.75)',
|
||||
background50: 'rgba(0, 0, 0, 0.5)',
|
||||
background25: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: '#8D9E8C',
|
||||
color: '#ffffff',
|
||||
success: '#66BB6A',
|
||||
secondary: '#9CCC65',
|
||||
primary: 'rgb(11, 128, 17)',
|
||||
danger: '#E57373',
|
||||
warning: '#FFB74D',
|
||||
neutral: '#8D9E8C',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
sunset: {
|
||||
dark: {
|
||||
background: 'rgb(52, 34, 28)',
|
||||
background75: 'rgba(52, 34, 28, 0.75)',
|
||||
background50: 'rgba(52, 34, 28, 0.5)',
|
||||
background25: 'rgba(52, 34, 28, 0.25)',
|
||||
borderColor: '#A1887F',
|
||||
color: '#ffffff',
|
||||
success: '#FFAB91',
|
||||
secondary: '#FF8A65',
|
||||
primary: '#FF7043',
|
||||
danger: '#EF5350',
|
||||
warning: '#FFCA28',
|
||||
neutral: '#A1887F',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
light: {
|
||||
background: '#FFF3E0',
|
||||
background75: 'rgba(255, 243, 224, 0.75)',
|
||||
background50: 'rgba(255, 243, 224, 0.5)',
|
||||
background25: 'rgba(255, 243, 224, 0.25)',
|
||||
borderColor: '#BF360C',
|
||||
color: '#3E2723',
|
||||
success: '#E64A19',
|
||||
secondary: '#FF5722',
|
||||
primary: '#FF5722',
|
||||
danger: '#B71C1C',
|
||||
warning: '#F57C00',
|
||||
neutral: '#BF360C',
|
||||
translucent: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
oled: {
|
||||
background: '#000000',
|
||||
background75: 'rgba(0, 0, 0, 0.75)',
|
||||
background50: 'rgba(0, 0, 0, 0.5)',
|
||||
background25: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: '#A1887F',
|
||||
color: '#ffffff',
|
||||
success: '#FFAB91',
|
||||
secondary: '#FF8A65',
|
||||
primary: '#FF7043',
|
||||
danger: '#EF5350',
|
||||
warning: '#FFCA28',
|
||||
neutral: '#A1887F',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
peanut: {
|
||||
dark: {
|
||||
background: 'rgba(62, 39, 22, 1)',
|
||||
background75: 'rgba(62, 39, 22, 0.75)',
|
||||
background50: 'rgba(62, 39, 22, 0.5)',
|
||||
background25: 'rgba(62, 39, 22, 0.25)',
|
||||
borderColor: '#BCAAA4',
|
||||
color: '#ffffff',
|
||||
success: '#D7CCC8',
|
||||
secondary: '#A1887F',
|
||||
primary: '#D7CCC8',
|
||||
danger: '#8D6E63',
|
||||
warning: '#FFAB91',
|
||||
neutral: '#BCAAA4',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
light: {
|
||||
background: '#EFEBE9',
|
||||
background75: 'rgba(239, 235, 233, 0.75)',
|
||||
background50: 'rgba(239, 235, 233, 0.5)',
|
||||
background25: 'rgba(239, 235, 233, 0.25)',
|
||||
borderColor: '#6D4C41',
|
||||
color: '#3E2723',
|
||||
success: '#5D4037',
|
||||
secondary: '#795548',
|
||||
primary: '#8D6E63',
|
||||
danger: '#4E342E',
|
||||
warning: '#BF360C',
|
||||
neutral: '#6D4C41',
|
||||
translucent: 'rgba(255, 255, 255, 0.75)',
|
||||
},
|
||||
oled: {
|
||||
background: '#000000',
|
||||
background75: 'rgba(0, 0, 0, 0.75)',
|
||||
background50: 'rgba(0, 0, 0, 0.5)',
|
||||
background25: 'rgba(0, 0, 0, 0.25)',
|
||||
borderColor: '#BCAAA4',
|
||||
color: '#ffffff',
|
||||
success: '#D7CCC8',
|
||||
secondary: '#A1887F',
|
||||
primary: '#D7CCC8',
|
||||
danger: '#8D6E63',
|
||||
warning: '#FFAB91',
|
||||
neutral: '#BCAAA4',
|
||||
translucent: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const presetNames = ['purple', 'ocean', 'forest', 'sunset', 'peanut'] as const
|
||||
|
||||
const themes: Record<string, PresetModePalette> = {}
|
||||
for (const preset of presetNames) {
|
||||
for (const mode of ['light', 'dark', 'oled'] as const) {
|
||||
themes[`${preset}_${mode}`] = PRESET_PALETTES[preset][mode]
|
||||
}
|
||||
}
|
||||
|
||||
const jellifyConfig = createTamagui({
|
||||
animations,
|
||||
fonts: {
|
||||
@@ -60,89 +329,7 @@ const jellifyConfig = createTamagui({
|
||||
media,
|
||||
shorthands,
|
||||
tokens,
|
||||
themes: {
|
||||
dark: {
|
||||
background: tokens.color.darkBackground,
|
||||
background75: tokens.color.darkBackground75,
|
||||
background50: tokens.color.darkBackground50,
|
||||
background25: tokens.color.darkBackground25,
|
||||
borderColor: tokens.color.neutral,
|
||||
color: tokens.color.white,
|
||||
success: tokens.color.tealDark,
|
||||
secondary: tokens.color.secondaryDark,
|
||||
primary: tokens.color.primaryDark,
|
||||
danger: tokens.color.dangerDark,
|
||||
warning: tokens.color.warningDark,
|
||||
neutral: tokens.color.neutral,
|
||||
|
||||
translucent: tokens.color.darkTranslucent,
|
||||
},
|
||||
oled: {
|
||||
// True black OLED theme
|
||||
background: tokens.color.black,
|
||||
background75: tokens.color.black75,
|
||||
background50: tokens.color.black50,
|
||||
background25: tokens.color.black25,
|
||||
borderColor: tokens.color.neutral,
|
||||
color: tokens.color.white,
|
||||
success: tokens.color.tealDark,
|
||||
secondary: tokens.color.secondaryDark,
|
||||
primary: tokens.color.primaryDark,
|
||||
danger: tokens.color.dangerDark,
|
||||
warning: tokens.color.warningDark,
|
||||
neutral: tokens.color.neutral,
|
||||
|
||||
translucent: tokens.color.darkTranslucent,
|
||||
},
|
||||
dark_inverted_purple: {
|
||||
color: tokens.color.purpleDark,
|
||||
borderColor: tokens.color.amethyst,
|
||||
background: tokens.color.amethyst,
|
||||
background25: tokens.color.amethyst25,
|
||||
background50: tokens.color.amethyst50,
|
||||
background75: tokens.color.amethyst75,
|
||||
success: tokens.color.tealDark,
|
||||
secondary: tokens.color.secondaryDark,
|
||||
primary: tokens.color.primaryDark,
|
||||
danger: tokens.color.dangerDark,
|
||||
warning: tokens.color.warningDark,
|
||||
neutral: tokens.color.neutral,
|
||||
|
||||
translucent: tokens.color.darkTranslucent,
|
||||
},
|
||||
light: {
|
||||
background: tokens.color.white,
|
||||
background75: tokens.color.lightBackground75,
|
||||
background50: tokens.color.lightBackground50,
|
||||
background25: tokens.color.lightBackground25,
|
||||
borderColor: tokens.color.neutral,
|
||||
color: tokens.color.purpleDark,
|
||||
success: tokens.color.tealLight,
|
||||
secondary: tokens.color.secondaryLight,
|
||||
primary: tokens.color.primaryLight,
|
||||
danger: tokens.color.dangerLight,
|
||||
warning: tokens.color.warningLight,
|
||||
neutral: tokens.color.neutral,
|
||||
|
||||
translucent: tokens.color.lightTranslucent,
|
||||
},
|
||||
light_inverted_purple: {
|
||||
color: tokens.color.purpleDark,
|
||||
borderColor: tokens.color.neutral,
|
||||
background: tokens.color.amethyst,
|
||||
background25: tokens.color.amethyst25,
|
||||
background50: tokens.color.amethyst50,
|
||||
background75: tokens.color.amethyst75,
|
||||
success: tokens.color.tealLight,
|
||||
secondary: tokens.color.secondaryLight,
|
||||
primary: tokens.color.primaryLight,
|
||||
danger: tokens.color.dangerLight,
|
||||
warning: tokens.color.warningLight,
|
||||
neutral: tokens.color.neutral,
|
||||
|
||||
translucent: tokens.color.lightTranslucent,
|
||||
},
|
||||
},
|
||||
themes,
|
||||
})
|
||||
|
||||
export type JellifyConfig = typeof jellifyConfig
|
||||
|
||||
Reference in New Issue
Block a user