feat: add cassette theme with customizable settings and UI components

- Introduced a new cassette theme configuration schema with various customizable options including body color, style, tape color, and more.
- Implemented the DefaultPlayer component for the default theme, featuring swipe gestures and animated controls.
- Created a theme registry to manage and load themes dynamically.
- Developed a schema system for theme customization, allowing users to export and import settings.
- Added a player theme settings screen for users to select and preview different themes.
- Established Zustand stores for managing player theme state and theme customizations.
This commit is contained in:
skalthoff
2026-01-14 13:14:45 -08:00
parent 46e9eabc80
commit aca1080921
33 changed files with 3101 additions and 198 deletions
+56 -144
View File
@@ -1,162 +1,74 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { YStack, ZStack, useWindowDimensions, View, getTokenValue } from 'tamagui'
import Scrubber from './components/scrubber'
import Controls from './components/controls'
import Footer from './components/footer'
import BlurredBackground from './components/blurred-background'
import PlayerHeader from './components/header'
import SongInfo from './components/song-info'
import { Spinner, useWindowDimensions, YStack } from 'tamagui'
import { useSharedValue } from 'react-native-reanimated'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { Platform } from 'react-native'
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../hooks/player/callbacks'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import Icon from '../Global/components/icon'
import { useCurrentTrack } from '../../stores/player/queue'
import { usePlayerTheme } from '../../stores/settings/player-theme'
import { themeRegistry } from './themes'
import type { PlayerThemeComponent, PlayerThemeProps } from './themes/types'
// Default theme is bundled for instant display
import DefaultTheme from './themes/default'
export default function PlayerScreen(): React.JSX.Element {
usePerformanceMonitor('PlayerScreen', 5)
const skip = useSkip()
const previous = usePrevious()
const trigger = useHapticFeedback()
const nowPlaying = useCurrentTrack()
const isAndroid = Platform.OS === 'android'
const [playerThemeId] = usePlayerTheme()
const [ThemeComponent, setThemeComponent] = useState<PlayerThemeComponent>(DefaultTheme)
const [isLoading, setIsLoading] = useState(playerThemeId !== 'default')
const { width, height } = useWindowDimensions()
const insets = useSafeAreaInsets()
const swipeX = useSharedValue(0)
const { top, bottom } = useSafeAreaInsets()
// Load selected theme
useEffect(() => {
if (playerThemeId === 'default') {
setThemeComponent(DefaultTheme)
setIsLoading(false)
return
}
// Shared animated value controlled by the large swipe area
const translateX = useSharedValue(0)
setIsLoading(true)
themeRegistry
.getTheme(playerThemeId)
.then(setThemeComponent)
.catch((error) => {
console.error('Failed to load theme:', error)
setThemeComponent(DefaultTheme) // Fallback
})
.finally(() => setIsLoading(false))
}, [playerThemeId])
// Edge icon opacity styles
const leftIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, -translateX.value), [0, 40, 120], [0, 0.25, 1]),
}))
const rightIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, translateX.value), [0, 40, 120], [0, 0.25, 1]),
}))
if (!nowPlaying) return <></>
// Let the native sheet gesture handle vertical dismissals; we only own horizontal swipes
const sheetDismissGesture = Gesture.Native()
// Gesture logic for central big swipe area
const swipeGesture = Gesture.Pan()
.activeOffsetX([-12, 12])
// Bail on vertical intent so native sheet dismiss keeps working
.failOffsetY([-8, 8])
.simultaneousWithExternalGesture(sheetDismissGesture)
.onUpdate((e) => {
if (Math.abs(e.translationY) < 40) {
translateX.value = Math.max(-160, Math.min(160, e.translationX))
}
})
.onEnd((e) => {
const threshold = 120
const minVelocity = 600
const isHorizontal = Math.abs(e.translationY) < 40
if (
isHorizontal &&
(Math.abs(e.translationX) > threshold || Math.abs(e.velocityX) > minVelocity)
) {
if (e.translationX > 0) {
// Inverted: swipe right = previous
translateX.value = withSpring(220)
runOnJS(trigger)('notificationSuccess')
runOnJS(previous)()
} else {
// Inverted: swipe left = next
translateX.value = withSpring(-220)
runOnJS(trigger)('notificationSuccess')
runOnJS(skip)(undefined)
}
translateX.value = withDelay(160, withSpring(0))
} else {
translateX.value = withSpring(0)
}
})
/**
* Styling for the top layer of Player ZStack
*
* Android Modals extend into the safe area, so we
* need to account for that
*
* Apple devices get a small amount of margin
*/
const mainContainerStyle = {
marginTop: isAndroid ? top : getTokenValue('$4'),
marginBottom: bottom + getTokenValue(isAndroid ? '$10' : '$12', 'space'),
if (isLoading) {
return (
<YStack
flex={1}
justifyContent='center'
alignItems='center'
backgroundColor='$background'
>
<Spinner size='large' color='$primary' />
</YStack>
)
}
return nowPlaying ? (
<ZStack width={width} height={height}>
<BlurredBackground />
const themeProps: PlayerThemeProps = {
nowPlaying,
swipeX,
dimensions: { width, height },
insets: {
top: insets.top,
bottom: insets.bottom,
left: insets.left,
right: insets.right,
},
}
{/* Swipe feedback icons (topmost overlay) */}
<Animated.View
pointerEvents='none'
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
>
<YStack flex={1} justifyContent='center'>
<Animated.View style={[{ position: 'absolute', left: 12 }, leftIconStyle]}>
<Icon name='skip-next' color='$primary' large />
</Animated.View>
<Animated.View style={[{ position: 'absolute', right: 12 }, rightIconStyle]}>
<Icon name='skip-previous' color='$primary' large />
</Animated.View>
</YStack>
</Animated.View>
{/* Central large swipe area overlay (captures swipe like big album art) */}
<GestureDetector gesture={Gesture.Simultaneous(sheetDismissGesture, swipeGesture)}>
<View
style={{
position: 'absolute',
top: height * 0.18,
left: width * 0.06,
right: width * 0.06,
height: height * 0.36,
zIndex: 9998,
}}
/>
</GestureDetector>
<YStack
justifyContent='center'
flex={1}
marginHorizontal={'$5'}
{...mainContainerStyle}
>
{/* flexGrow 1 */}
<PlayerHeader />
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
<SongInfo />
<Scrubber />
<Controls />
<Footer />
</YStack>
</YStack>
</ZStack>
) : (
<></>
)
return <ThemeComponent.Player {...themeProps} />
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Progress, XStack, YStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
import { PlayPauseIcon } from './components/buttons'
import { PlayPauseIcon } from './themes/default/components/buttons'
import { TextTickerConfig } from './component.config'
import { UPDATE_INTERVAL } from '../../configs/player.config'
import { Progress as TrackPlayerProgress } from 'react-native-track-player'
@@ -0,0 +1,65 @@
import { Gesture, SimultaneousGesture } from 'react-native-gesture-handler'
import { SharedValue, withDelay, withSpring } from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
interface UsePlayerGesturesOptions {
swipeX: SharedValue<number>
onSkipNext: () => void
onSkipPrevious: () => void
onHapticFeedback: (type: string) => void
/** Whether to invert swipe direction (default: true for natural feel) */
invertDirection?: boolean
/** Swipe threshold in pixels */
threshold?: number
/** Minimum velocity to trigger skip */
minVelocity?: number
}
export function usePlayerGestures({
swipeX,
onSkipNext,
onSkipPrevious,
onHapticFeedback,
invertDirection = true,
threshold = 120,
minVelocity = 600,
}: UsePlayerGesturesOptions): SimultaneousGesture {
// Let the native sheet gesture handle vertical dismissals; we only own horizontal swipes
const sheetDismissGesture = Gesture.Native()
const swipeGesture = Gesture.Pan()
.activeOffsetX([-12, 12])
// Bail on vertical intent so native sheet dismiss keeps working
.failOffsetY([-8, 8])
.simultaneousWithExternalGesture(sheetDismissGesture)
.onUpdate((e) => {
if (Math.abs(e.translationY) < 40) {
swipeX.value = Math.max(-160, Math.min(160, e.translationX))
}
})
.onEnd((e) => {
const isHorizontal = Math.abs(e.translationY) < 40
if (
isHorizontal &&
(Math.abs(e.translationX) > threshold || Math.abs(e.velocityX) > minVelocity)
) {
const isRightSwipe = e.translationX > 0
const action = invertDirection
? isRightSwipe
? onSkipPrevious
: onSkipNext
: isRightSwipe
? onSkipNext
: onSkipPrevious
swipeX.value = withSpring(isRightSwipe ? 220 : -220)
runOnJS(onHapticFeedback)('notificationSuccess')
runOnJS(action)()
swipeX.value = withDelay(160, withSpring(0))
} else {
swipeX.value = withSpring(0)
}
})
return Gesture.Simultaneous(sheetDismissGesture, swipeGesture)
}
+375
View File
@@ -0,0 +1,375 @@
# Player Themes System
## Overview
Jellify supports customizable player themes, allowing users to choose different visual designs for the full-screen music player. The theming system is designed for:
- **Performance**: Themes are lazy-loaded to minimize bundle size impact
- **Extensibility**: New themes can be added with minimal boilerplate
- **Consistency**: All themes share common playback hooks and utilities
## User Guide
### Selecting a Theme
1. Open **Settings**
2. Navigate to **Appearance** (or directly to **Player Theme**)
3. Browse available themes with live previews
4. Tap a theme to select it
5. Changes apply immediately
### Available Themes
| Theme | Description | Status |
|-------|-------------|--------|
| **Modern** (Default) | Clean, minimal design with large album artwork focus | Stable |
| **Cassette** | Retro tape deck with spinning reels and vintage aesthetics | Experimental |
## Architecture
```
src/components/Player/
├── index.tsx # Theme-aware router
├── themes/
│ ├── index.ts # Theme registry + lazy loading
│ ├── types.ts # TypeScript interfaces
│ ├── README.md # This file
│ ├── default/
│ │ ├── index.tsx # Modern theme entry
│ │ └── components/ # Theme-specific components
│ └── cassette/
│ ├── index.tsx # Cassette theme entry
│ └── components/ # Tape deck, controls, etc.
├── shared/
│ └── hooks/
│ └── use-player-gestures.ts # Shared gesture handling
└── ...
```
## Developer Guide: Creating a New Theme
### Step 1: Create Theme Directory
```bash
mkdir -p src/components/Player/themes/your-theme/components
```
### Step 2: Add Theme ID
Edit `src/stores/settings/player-theme.ts`:
```typescript
export type PlayerThemeId = 'default' | 'cassette' | 'your-theme'
```
### Step 3: Implement Theme Component
Create `src/components/Player/themes/your-theme/index.tsx`:
```typescript
import React from 'react'
import type { PlayerThemeComponent, PlayerThemeProps } from '../types'
function YourThemePlayer({
nowPlaying,
swipeX,
dimensions,
insets
}: PlayerThemeProps): React.JSX.Element {
// Your player UI here
// Use shared hooks for playback control:
// - useProgress() for playback position
// - usePlaybackState() for play/pause state
// - useSkip(), usePrevious() for track navigation
// - useSeekTo() for scrubbing
return (
// Your JSX
)
}
function YourThemePreview({ width, height }: { width: number; height: number }): React.JSX.Element {
// Static preview for settings screen (no playback logic)
return (
// Preview JSX
)
}
const YourTheme: PlayerThemeComponent = {
Player: YourThemePlayer,
Preview: YourThemePreview,
metadata: {
id: 'your-theme',
name: 'Your Theme',
description: 'A brief description',
icon: 'material-design-icon-name',
experimental: true, // Set to false when stable
},
}
export default YourTheme
```
### Step 4: Register Theme
Edit `src/components/Player/themes/index.ts`:
```typescript
const themeLoaders: Record<PlayerThemeId, ThemeLoader> = {
default: () => import('./default'),
cassette: () => import('./cassette'),
'your-theme': () => import('./your-theme'), // Add this
}
export const THEME_METADATA: Record<PlayerThemeId, PlayerThemeMetadata> = {
// ... existing themes
'your-theme': {
id: 'your-theme',
name: 'Your Theme',
description: 'A brief description',
icon: 'material-design-icon-name',
experimental: true,
},
}
```
### Step 5: Implement Swipe Gestures (Recommended)
Use the shared gesture hook for consistent skip/previous behavior:
```typescript
import { usePlayerGestures } from '../../shared/hooks/use-player-gestures'
function YourThemePlayer({ swipeX, ... }: PlayerThemeProps) {
const skip = useSkip()
const previous = usePrevious()
const trigger = useHapticFeedback()
const gesture = usePlayerGestures({
swipeX,
onSkipNext: () => skip(undefined),
onSkipPrevious: previous,
onHapticFeedback: (type) => trigger(type),
})
return (
<GestureDetector gesture={gesture}>
{/* Your swipeable area */}
</GestureDetector>
)
}
```
## Theme Props Reference
```typescript
interface PlayerThemeProps {
/** Current track data */
nowPlaying: JellifyTrack
/** Shared animated value for horizontal swipe gestures */
swipeX: SharedValue<number>
/** Screen dimensions from useWindowDimensions */
dimensions: { width: number; height: number }
/** Safe area insets for proper spacing */
insets: { top: number; bottom: number; left: number; right: number }
}
```
## Useful Hooks
| Hook | Purpose |
|------|---------|
| `useProgress(interval)` | Get current position/duration |
| `usePlaybackState()` | Get play/pause/buffering state |
| `useCurrentTrack()` | Get current track from queue |
| `useSkip()` | Skip to next track |
| `usePrevious()` | Go to previous track |
| `useSeekTo()` | Seek to specific position |
| `useTogglePlayback()` | Toggle play/pause |
| `useToggleShuffle()` | Toggle shuffle mode |
| `useToggleRepeatMode()` | Cycle repeat modes |
## Best Practices
1. **Keep previews lightweight**: Don't include playback logic in Preview components
2. **Use relative imports**: Avoid `@/` aliases for better portability
3. **Handle safe areas**: Use the `insets` prop for proper spacing
4. **Support both platforms**: Test on iOS and Android
5. **Respect theme colors**: Use Tamagui theme tokens where appropriate
6. **Add loading states**: Handle buffering/loading gracefully
---
## Theme Customization System
Themes can expose customizable settings via a JSON schema. The settings UI is auto-generated, and users can also export/import their customizations as JSON files.
### Creating a Theme Config
Create `theme.config.ts` in your theme directory:
```typescript
import type { ThemeConfigSchema } from '../schema'
const myThemeConfig: ThemeConfigSchema = {
version: 1,
meta: {
id: 'my-theme',
name: 'My Theme',
description: 'A customizable theme',
icon: 'palette',
},
settings: {
// Group related settings
colors: {
type: 'group',
label: 'Colors',
settings: {
primary: {
type: 'color',
label: 'Primary Color',
default: '#FF6B6B',
},
background: {
type: 'color',
label: 'Background',
default: '#1A1A1A',
},
},
},
// Toggle settings
animations: {
type: 'toggle',
label: 'Enable Animations',
description: 'Animate UI elements',
default: true,
},
// Slider settings
artworkSize: {
type: 'slider',
label: 'Artwork Size',
min: 200,
max: 400,
step: 10,
default: 300,
unit: 'px',
},
// Choice settings
style: {
type: 'choice',
label: 'Visual Style',
default: 'modern',
options: [
{ value: 'modern', label: 'Modern' },
{ value: 'classic', label: 'Classic' },
{ value: 'minimal', label: 'Minimal' },
],
},
},
// Optional presets
presets: [
{
id: 'dark-mode',
name: 'Dark Mode',
values: {
'colors.primary': '#BB86FC',
'colors.background': '#121212',
},
},
],
}
export default myThemeConfig
```
### Using Settings in Components
```typescript
import { useResolvedThemeSettings } from '../../../stores/settings/theme-customization'
import myThemeConfig from './theme.config'
function MyThemePlayer() {
// Get all resolved settings (defaults + user customizations)
const settings = useResolvedThemeSettings('my-theme', myThemeConfig)
// Access settings with dot notation
const primaryColor = settings['colors.primary'] as string
const showAnimations = settings['animations'] as boolean
return (
<View style={{ backgroundColor: settings['colors.background'] }}>
{/* ... */}
</View>
)
}
```
### Strongly-Typed Settings Hook
For better DX, create a custom hook with full typing:
```typescript
// hooks/use-my-theme-settings.ts
import { useMemo } from 'react'
import { useResolvedThemeSettings } from '../../../stores/settings/theme-customization'
import myThemeConfig from '../theme.config'
interface MyThemeSettings {
colors: { primary: string; background: string }
animations: boolean
artworkSize: number
style: 'modern' | 'classic' | 'minimal'
}
export function useMyThemeSettings(): MyThemeSettings {
const resolved = useResolvedThemeSettings('my-theme', myThemeConfig)
return useMemo(() => ({
colors: {
primary: resolved['colors.primary'] as string,
background: resolved['colors.background'] as string,
},
animations: resolved['animations'] as boolean,
artworkSize: resolved['artworkSize'] as number,
style: resolved['style'] as MyThemeSettings['style'],
}), [resolved])
}
```
### Setting Types Reference
| Type | Description | Properties |
|------|-------------|------------|
| `color` | Color picker | `default: string` (hex) |
| `toggle` | Boolean switch | `default: boolean` |
| `slider` | Numeric slider | `default, min, max, step, unit?` |
| `choice` | Radio/dropdown | `default, options: [{value, label}]` |
| `group` | Nested settings | `settings: Record<string, Setting>` |
### Export/Import Format
Users can export their customizations as `.jellify-theme` JSON files:
```json
{
"$type": "jellify-theme-customization",
"$version": 1,
"themeId": "cassette",
"themeName": "Cassette",
"exportedAt": "2024-01-15T10:30:00Z",
"values": {
"cassette.bodyColor": "#1A1A1A",
"reels.animate": true,
"counter.style": "led"
}
}
```
This allows enthusiasts to share their configurations with the community.
@@ -0,0 +1,25 @@
import React from 'react'
import { YStack } from 'tamagui'
import LinearGradient from 'react-native-linear-gradient'
interface CassetteBackgroundProps {
width: number
height: number
}
export default function CassetteBackground({
width,
height,
}: CassetteBackgroundProps): React.JSX.Element {
return (
<YStack position='absolute' width={width} height={height}>
<LinearGradient
colors={['#2C1810', '#4A3728', '#3D2A1F', '#1A0F0A']}
locations={[0, 0.3, 0.7, 1]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{ flex: 1 }}
/>
</YStack>
)
}
@@ -0,0 +1,173 @@
import React from 'react'
import { YStack, XStack } from 'tamagui'
import { State, RepeatMode } from 'react-native-track-player'
import Icon from '../../../../Global/components/icon'
import {
useTogglePlayback,
usePrevious,
useSkip,
useToggleShuffle,
useToggleRepeatMode,
} from '../../../../../hooks/player/callbacks'
import { usePlaybackState } from '../../../../../hooks/player/queries'
import { useShuffle, useRepeatModeStoreValue } from '../../../../../stores/player/queue'
export default function CassetteControls(): React.JSX.Element {
const playbackState = usePlaybackState()
const togglePlayback = useTogglePlayback()
const previous = usePrevious()
const skip = useSkip()
const toggleShuffle = useToggleShuffle()
const toggleRepeatMode = useToggleRepeatMode()
const shuffled = useShuffle()
const repeatMode = useRepeatModeStoreValue()
const isPlaying = playbackState === State.Playing
return (
<YStack gap='$3' alignItems='center'>
{/* Main transport controls */}
<XStack gap='$3' alignItems='center' justifyContent='center'>
{/* Rewind/Previous */}
<TransportButton onPress={previous} size={56}>
<XStack>
<Icon name='skip-previous' color='$color' />
</XStack>
</TransportButton>
{/* Play/Pause */}
<TransportButton onPress={() => togglePlayback(playbackState)} size={72} primary>
{isPlaying ? (
<XStack gap='$1'>
<YStack
width={8}
height={24}
backgroundColor='$color'
borderRadius={2}
/>
<YStack
width={8}
height={24}
backgroundColor='$color'
borderRadius={2}
/>
</XStack>
) : (
<YStack
width={0}
height={0}
borderLeftWidth={20}
borderTopWidth={12}
borderBottomWidth={12}
borderLeftColor='$color'
borderTopColor='transparent'
borderBottomColor='transparent'
marginLeft={4}
/>
)}
</TransportButton>
{/* Fast Forward/Next */}
<TransportButton onPress={() => skip(undefined)} size={56}>
<XStack>
<Icon name='skip-next' color='$color' />
</XStack>
</TransportButton>
</XStack>
{/* Secondary controls */}
<XStack gap='$4' alignItems='center' justifyContent='center'>
{/* Shuffle */}
<SecondaryButton
onPress={() => toggleShuffle(shuffled)}
active={shuffled}
icon='shuffle'
/>
{/* Repeat */}
<SecondaryButton
onPress={toggleRepeatMode}
active={repeatMode !== RepeatMode.Off}
icon={repeatMode === RepeatMode.Track ? 'repeat-once' : 'repeat'}
/>
</XStack>
</YStack>
)
}
interface TransportButtonProps {
onPress: () => void
size: number
primary?: boolean
children: React.ReactNode
}
function TransportButton({
onPress,
size,
primary,
children,
}: TransportButtonProps): React.JSX.Element {
return (
<YStack
width={size}
height={size * 0.7}
backgroundColor='#4A4A4A'
borderRadius={6}
alignItems='center'
justifyContent='center'
onPress={onPress}
pressStyle={{
backgroundColor: '#3A3A3A',
scale: 0.95,
}}
animation='quick'
shadowColor='#000'
shadowOffset={{ width: 0, height: 2 }}
shadowOpacity={0.4}
shadowRadius={3}
elevation={4}
borderWidth={1}
borderTopColor='#6A6A6A'
borderLeftColor='#6A6A6A'
borderRightColor='#2A2A2A'
borderBottomColor='#2A2A2A'
{...(primary && {
backgroundColor: '#5A3A2A',
borderTopColor: '#7A5A4A',
borderLeftColor: '#7A5A4A',
borderRightColor: '#3A2A1A',
borderBottomColor: '#3A2A1A',
})}
>
{children}
</YStack>
)
}
interface SecondaryButtonProps {
onPress: () => void
active: boolean
icon: string
}
function SecondaryButton({ onPress, active, icon }: SecondaryButtonProps): React.JSX.Element {
return (
<YStack
width={44}
height={44}
borderRadius={22}
backgroundColor={active ? '#5A3A2A' : '#3A3A3A'}
alignItems='center'
justifyContent='center'
onPress={onPress}
pressStyle={{ scale: 0.9 }}
animation='quick'
borderWidth={2}
borderColor={active ? '$warning' : '#4A4A4A'}
>
<Icon name={icon} color={active ? '$warning' : '$color'} small />
</YStack>
)
}
@@ -0,0 +1,50 @@
import React from 'react'
import { XStack } from 'tamagui'
import Icon from '../../../../Global/components/icon'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { PlayerParamList } from '../../../../../screens/Player/types'
import GoogleCast from 'react-native-google-cast'
export default function CassetteFooter(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
return (
<XStack justifyContent='center' gap='$6' paddingVertical='$2'>
{/* Cast button */}
<FooterButton icon='cast' onPress={() => GoogleCast.showCastDialog()} />
{/* Queue button */}
<FooterButton
icon='playlist-music'
onPress={() => navigation.navigate('QueueScreen')}
/>
</XStack>
)
}
interface FooterButtonProps {
icon: string
onPress: () => void
active?: boolean
}
function FooterButton({ icon, onPress, active }: FooterButtonProps): React.JSX.Element {
return (
<XStack
width={44}
height={44}
borderRadius={22}
backgroundColor='#2A2A2A'
alignItems='center'
justifyContent='center'
onPress={onPress}
pressStyle={{ scale: 0.9, opacity: 0.8 }}
animation='quick'
borderWidth={1}
borderColor='#3A3A3A'
>
<Icon name={icon} color={active ? '$warning' : '$borderColor'} small />
</XStack>
)
}
@@ -0,0 +1,22 @@
import React from 'react'
import { XStack, SizableText } from 'tamagui'
import Icon from '../../../../Global/components/icon'
import navigationRef from '../../../../../../navigation'
export default function CassetteHeader(): React.JSX.Element {
return (
<XStack justifyContent='space-between' alignItems='center' paddingHorizontal='$2'>
<XStack
onPress={() => navigationRef.goBack()}
pressStyle={{ opacity: 0.7 }}
padding='$2'
>
<Icon name='chevron-down' color='$borderColor' />
</XStack>
<SizableText size='$2' color='$borderColor' fontWeight='600' letterSpacing={2}>
NOW PLAYING
</SizableText>
<XStack width={40} /> {/* Spacer for balance */}
</XStack>
)
}
@@ -0,0 +1,422 @@
import React from 'react'
import { YStack, XStack, SizableText } from 'tamagui'
import Animated, {
useAnimatedStyle,
useDerivedValue,
withRepeat,
withTiming,
Easing,
SharedValue,
} from 'react-native-reanimated'
import { useProgress, usePlaybackState } from '../../../../../hooks/player/queries'
import { UPDATE_INTERVAL } from '../../../../../configs/player.config'
import { State } from 'react-native-track-player'
import type JellifyTrack from '../../../../../types/JellifyTrack'
import ItemImage from '../../../../Global/components/image'
import LinearGradient from 'react-native-linear-gradient'
import { StyleSheet } from 'react-native'
interface TapeDeckProps {
nowPlaying: JellifyTrack
swipeX: SharedValue<number>
width: number
}
const CASSETTE_COLORS = {
body: '#D4C4B5',
bodyDark: '#B8A89A',
bodyHighlight: '#E8DDD0',
window: '#1A1A1A',
windowFrame: '#0D0D0D',
reel: '#2D2D2D',
reelCenter: '#4A4A4A',
reelHighlight: '#5A5A5A',
tape: '#3D2A1F',
tapeShine: '#4D3A2F',
label: '#F5E6D3',
labelText: '#2C1810',
screw: '#8B7355',
screwHighlight: '#A08060',
}
export default function TapeDeck({ nowPlaying, width }: TapeDeckProps): React.JSX.Element {
const progress = useProgress(UPDATE_INTERVAL)
const playbackState = usePlaybackState()
const isPlaying = playbackState === State.Playing
const cassetteWidth = Math.min(width - 40, 340)
const cassetteHeight = cassetteWidth * 0.65
const progressPercent = progress.duration > 0 ? progress.position / progress.duration : 0
return (
<YStack alignItems='center' gap='$4'>
{/* Cassette body */}
<YStack
width={cassetteWidth}
height={cassetteHeight}
backgroundColor={CASSETTE_COLORS.body}
borderRadius={12}
padding='$2'
shadowColor='#000'
shadowOffset={{ width: 0, height: 6 }}
shadowOpacity={0.4}
shadowRadius={12}
elevation={10}
borderWidth={1}
borderColor={CASSETTE_COLORS.bodyHighlight}
>
{/* Top edge detail / highlight */}
<YStack
position='absolute'
top={0}
left={0}
right={0}
height={6}
backgroundColor={CASSETTE_COLORS.bodyHighlight}
borderTopLeftRadius={12}
borderTopRightRadius={12}
opacity={0.7}
zIndex={1}
/>
{/* Bottom edge shadow */}
<YStack
position='absolute'
bottom={0}
left={0}
right={0}
height={4}
backgroundColor={CASSETTE_COLORS.bodyDark}
borderBottomLeftRadius={12}
borderBottomRightRadius={12}
zIndex={1}
/>
{/* Screw holes - highest z-index to appear on top */}
<XStack
position='absolute'
top={10}
left={12}
right={12}
justifyContent='space-between'
zIndex={10}
>
<ScrewHole />
<ScrewHole />
</XStack>
<XStack
position='absolute'
bottom={10}
left={12}
right={12}
justifyContent='space-between'
zIndex={10}
>
<ScrewHole />
<ScrewHole />
</XStack>
{/* Window area with reels */}
<YStack
flex={1}
marginTop='$3'
marginHorizontal='$1.5'
backgroundColor={CASSETTE_COLORS.windowFrame}
borderRadius={8}
padding={3}
overflow='hidden'
zIndex={2}
>
<YStack
flex={1}
backgroundColor={CASSETTE_COLORS.window}
borderRadius={6}
paddingVertical='$2'
paddingHorizontal='$3'
justifyContent='center'
overflow='hidden'
>
<XStack justifyContent='space-between' alignItems='center'>
{/* Left reel (supply - empties as song plays) */}
<TapeReel
size={cassetteWidth * 0.18}
isPlaying={isPlaying}
tapeAmount={1 - progressPercent}
direction={1}
/>
{/* Tape window showing tape between reels */}
<TapeWindow width={cassetteWidth * 0.2} />
{/* Right reel (take-up - fills as song plays) */}
<TapeReel
size={cassetteWidth * 0.18}
isPlaying={isPlaying}
tapeAmount={progressPercent}
direction={1}
/>
</XStack>
</YStack>
</YStack>
{/* Label area with album art */}
<YStack
borderRadius={6}
marginTop='$1.5'
marginHorizontal='$3'
overflow='hidden'
height={cassetteHeight * 0.28}
zIndex={5}
>
{/* Album art as background - base layer */}
<YStack position='absolute' top={0} left={0} right={0} bottom={0} zIndex={1}>
<ItemImage
item={nowPlaying.item}
imageOptions={{ maxWidth: 400, maxHeight: 200 }}
/>
</YStack>
{/* Gradient overlay for text readability */}
<LinearGradient
colors={['rgba(0,0,0,0.3)', 'rgba(0,0,0,0.7)']}
style={[StyleSheet.absoluteFill, { zIndex: 2 }]}
/>
{/* Vintage label overlay effect */}
<YStack
position='absolute'
top={0}
left={0}
right={0}
bottom={0}
backgroundColor='rgba(245, 230, 211, 0.15)'
zIndex={3}
/>
{/* Text content - above overlays */}
<YStack flex={1} justifyContent='center' padding='$2' gap='$0.5' zIndex={4}>
<SizableText
size='$3'
fontWeight='700'
color='#FFFFFF'
numberOfLines={1}
textAlign='center'
textShadowColor='rgba(0,0,0,0.8)'
textShadowOffset={{ width: 0, height: 1 }}
textShadowRadius={3}
>
{nowPlaying.title ?? 'Unknown Track'}
</SizableText>
<SizableText
size='$2'
color='rgba(255,255,255,0.85)'
numberOfLines={1}
textAlign='center'
textShadowColor='rgba(0,0,0,0.8)'
textShadowOffset={{ width: 0, height: 1 }}
textShadowRadius={2}
>
{nowPlaying.artist ?? 'Unknown Artist'}
</SizableText>
</YStack>
{/* Subtle label border/frame - topmost */}
<YStack
position='absolute'
top={0}
left={0}
right={0}
bottom={0}
borderWidth={2}
borderColor='rgba(139, 115, 85, 0.4)'
borderRadius={6}
zIndex={5}
/>
</YStack>
</YStack>
</YStack>
)
}
function ScrewHole(): React.JSX.Element {
return (
<YStack
width={10}
height={10}
borderRadius={5}
backgroundColor={CASSETTE_COLORS.screw}
borderWidth={1.5}
borderColor='#6B5344'
alignItems='center'
justifyContent='center'
>
{/* Phillips head cross */}
<YStack
position='absolute'
width={6}
height={1.5}
backgroundColor='#5A4334'
borderRadius={0.5}
/>
<YStack
position='absolute'
width={1.5}
height={6}
backgroundColor='#5A4334'
borderRadius={0.5}
/>
</YStack>
)
}
interface TapeReelProps {
size: number
isPlaying: boolean
tapeAmount: number // 0-1, how much tape is on this reel
direction: number // 1 or -1 for rotation direction
}
function TapeReel({ size, isPlaying, tapeAmount, direction }: TapeReelProps): React.JSX.Element {
const rotation = useDerivedValue(() => {
if (!isPlaying) return 0
return withRepeat(
withTiming(360 * direction, {
duration: 2000,
easing: Easing.linear,
}),
-1,
false,
)
}, [isPlaying, direction])
const reelStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}))
// Calculate tape radius based on amount (min 30% of reel size, max 48% to stay within bounds)
// The reel container is `size`, so max radius should be size/2 = 50%, using 48% for safety
const minTapeRadius = size * 0.3
const maxTapeRadius = size * 0.48
const tapeRadius = minTapeRadius + tapeAmount * (maxTapeRadius - minTapeRadius)
const coreSize = size * 0.4
return (
<YStack width={size} height={size} alignItems='center' justifyContent='center'>
{/* Tape wrapped around reel - base layer */}
<YStack
position='absolute'
width={tapeRadius * 2}
height={tapeRadius * 2}
borderRadius={tapeRadius}
backgroundColor={CASSETTE_COLORS.tape}
zIndex={1}
/>
{/* Tape shine/highlight */}
<YStack
position='absolute'
width={tapeRadius * 1.8}
height={tapeRadius * 1.8}
borderRadius={tapeRadius * 0.9}
borderWidth={1}
borderColor={CASSETTE_COLORS.tapeShine}
opacity={0.3}
zIndex={2}
/>
{/* Reel hub - top layer */}
<Animated.View style={[reelStyle, { zIndex: 3 }]}>
<YStack
width={coreSize}
height={coreSize}
borderRadius={coreSize / 2}
backgroundColor={CASSETTE_COLORS.reel}
alignItems='center'
justifyContent='center'
borderWidth={1}
borderColor='#3D3D3D'
>
{/* Center hub with highlight */}
<YStack
width={coreSize * 0.45}
height={coreSize * 0.45}
borderRadius={coreSize * 0.225}
backgroundColor={CASSETTE_COLORS.reelCenter}
borderWidth={1}
borderColor={CASSETTE_COLORS.reelHighlight}
/>
{/* Spokes */}
{[0, 60, 120, 180, 240, 300].map((angle) => (
<YStack
key={angle}
position='absolute'
width={2}
height={coreSize * 0.42}
backgroundColor={CASSETTE_COLORS.reelCenter}
style={{ transform: [{ rotate: `${angle}deg` }] }}
borderRadius={1}
/>
))}
</YStack>
</Animated.View>
</YStack>
)
}
interface TapeWindowProps {
width: number
}
function TapeWindow({ width }: TapeWindowProps): React.JSX.Element {
return (
<YStack
width={width}
height={24}
backgroundColor='#0A0A0A'
borderRadius={3}
overflow='hidden'
justifyContent='center'
borderWidth={1}
borderColor='#1A1A1A'
>
{/* Tape running through window */}
<YStack
position='absolute'
left={0}
right={0}
height={10}
backgroundColor={CASSETTE_COLORS.tape}
top={7}
/>
{/* Tape shine line */}
<YStack
position='absolute'
left={0}
right={0}
height={1}
backgroundColor={CASSETTE_COLORS.tapeShine}
top={10}
opacity={0.4}
/>
{/* Tape guides */}
<XStack justifyContent='space-between' paddingHorizontal={3}>
<YStack
width={5}
height={18}
backgroundColor='#2A2A2A'
borderRadius={1}
borderWidth={1}
borderColor='#3A3A3A'
/>
<YStack
width={5}
height={18}
backgroundColor='#2A2A2A'
borderRadius={1}
borderWidth={1}
borderColor='#3A3A3A'
/>
</XStack>
</YStack>
)
}
@@ -0,0 +1,133 @@
import React from 'react'
import { YStack, XStack, SizableText } from 'tamagui'
import { useProgress } from '../../../../../hooks/player/queries'
import { useSeekTo } from '../../../../../hooks/player/callbacks'
import { UPDATE_INTERVAL } from '../../../../../configs/player.config'
import { Slider } from 'tamagui'
import useHapticFeedback from '../../../../../hooks/use-haptic-feedback'
const COUNTER_COLORS = {
background: '#1A1A1A',
digit: '#E8B87D',
border: '#3A3A3A',
}
export default function TapeScrubber(): React.JSX.Element {
const progress = useProgress(UPDATE_INTERVAL)
const seekTo = useSeekTo()
const trigger = useHapticFeedback()
const position = progress.position || 0
const duration = progress.duration || 1
const handleSeek = (value: number) => {
trigger('impactLight')
seekTo(value)
}
return (
<YStack gap='$3' paddingHorizontal='$4'>
{/* Mechanical counter display */}
<XStack justifyContent='center' gap='$4'>
<CounterDisplay value={position} label='POSITION' />
<YStack width={1} backgroundColor='#4A4A4A' marginVertical='$1' />
<CounterDisplay value={duration} label='DURATION' />
</XStack>
{/* Slider styled like a tape deck slider */}
<YStack
backgroundColor='#2A2A2A'
borderRadius={4}
padding='$1'
borderWidth={1}
borderColor={COUNTER_COLORS.border}
>
<Slider
value={[position]}
min={0}
max={duration}
step={1}
onValueChange={(values) => handleSeek(values[0])}
>
<Slider.Track backgroundColor='#1A1A1A' height={8} borderRadius={4}>
<Slider.TrackActive
backgroundColor={COUNTER_COLORS.digit}
borderRadius={4}
/>
</Slider.Track>
<Slider.Thumb
index={0}
circular
size='$1.5'
backgroundColor='#D4C4B5'
borderWidth={2}
borderColor='#8B7355'
shadowColor='#000'
shadowOffset={{ width: 0, height: 2 }}
shadowOpacity={0.3}
shadowRadius={2}
elevation={3}
/>
</Slider>
</YStack>
</YStack>
)
}
interface CounterDisplayProps {
value: number
label: string
}
function CounterDisplay({ value, label }: CounterDisplayProps): React.JSX.Element {
const minutes = Math.floor(value / 60)
const seconds = Math.floor(value % 60)
return (
<YStack alignItems='center' gap='$1'>
<SizableText size='$1' color='#8A8A8A' fontWeight='600' letterSpacing={1}>
{label}
</SizableText>
<XStack
backgroundColor={COUNTER_COLORS.background}
paddingHorizontal='$2'
paddingVertical='$1'
borderRadius={4}
borderWidth={1}
borderColor={COUNTER_COLORS.border}
gap='$0.5'
>
<DigitDisplay value={Math.floor(minutes / 10)} />
<DigitDisplay value={minutes % 10} />
<SizableText fontSize={20} fontWeight='700' color={COUNTER_COLORS.digit}>
:
</SizableText>
<DigitDisplay value={Math.floor(seconds / 10)} />
<DigitDisplay value={seconds % 10} />
</XStack>
</YStack>
)
}
interface DigitDisplayProps {
value: number
}
function DigitDisplay({ value }: DigitDisplayProps): React.JSX.Element {
return (
<YStack
width={18}
height={28}
backgroundColor='#0D0D0D'
borderRadius={2}
alignItems='center'
justifyContent='center'
borderWidth={1}
borderColor='#2A2A2A'
>
<SizableText fontSize={20} fontWeight='700' color={COUNTER_COLORS.digit}>
{value}
</SizableText>
</YStack>
)
}
@@ -0,0 +1,147 @@
import { useMemo } from 'react'
import { useResolvedThemeSettings } from '../../../../../stores/settings/theme-customization'
import cassetteConfig from '../theme.config'
import type { ResolvedSettings } from '../../schema'
/**
* Cassette theme settings interface with strong typing
*/
export interface CassetteSettings {
// Cassette body
cassette: {
bodyColor: string
bodyStyle: 'classic' | 'clear' | 'black' | 'white'
showScrews: boolean
shadowIntensity: number
}
// Tape reels
reels: {
tapeColor: string
animate: boolean
speed: number
showTapeProgress: boolean
}
// Label area
label: {
style: 'album-art' | 'vintage' | 'typed' | 'minimal'
vintageOverlay: boolean
labelColor: string
}
// Counter display
counter: {
style: 'mechanical' | 'digital' | 'led' | 'hidden'
digitColor: string
showDuration: boolean
}
// Controls
controls: {
style: 'raised' | 'flat' | 'chrome'
buttonColor: string
haptics: boolean
}
// Background
background: {
style: 'gradient' | 'solid' | 'wood' | 'album-blur'
color: string
opacity: number
}
}
/**
* Parse flat resolved settings into nested CassetteSettings structure
*/
function parseSettings(resolved: ResolvedSettings): CassetteSettings {
return {
cassette: {
bodyColor: (resolved['cassette.bodyColor'] as string) ?? '#D4C4B5',
bodyStyle:
(resolved['cassette.bodyStyle'] as CassetteSettings['cassette']['bodyStyle']) ??
'classic',
showScrews: (resolved['cassette.showScrews'] as boolean) ?? true,
shadowIntensity: (resolved['cassette.shadowIntensity'] as number) ?? 0.4,
},
reels: {
tapeColor: (resolved['reels.tapeColor'] as string) ?? '#3D2A1F',
animate: (resolved['reels.animate'] as boolean) ?? true,
speed: (resolved['reels.speed'] as number) ?? 1.0,
showTapeProgress: (resolved['reels.showTapeProgress'] as boolean) ?? true,
},
label: {
style: (resolved['label.style'] as CassetteSettings['label']['style']) ?? 'album-art',
vintageOverlay: (resolved['label.vintageOverlay'] as boolean) ?? true,
labelColor: (resolved['label.labelColor'] as string) ?? '#F5E6D3',
},
counter: {
style:
(resolved['counter.style'] as CassetteSettings['counter']['style']) ?? 'mechanical',
digitColor: (resolved['counter.digitColor'] as string) ?? '#E8B87D',
showDuration: (resolved['counter.showDuration'] as boolean) ?? true,
},
controls: {
style:
(resolved['controls.style'] as CassetteSettings['controls']['style']) ?? 'raised',
buttonColor: (resolved['controls.buttonColor'] as string) ?? '#5A3A2A',
haptics: (resolved['controls.haptics'] as boolean) ?? true,
},
background: {
style:
(resolved['background.style'] as CassetteSettings['background']['style']) ??
'gradient',
color: (resolved['background.color'] as string) ?? '#2C1810',
opacity: (resolved['background.opacity'] as number) ?? 0.85,
},
}
}
/**
* Hook to get strongly-typed cassette theme settings
*
* Usage:
* ```tsx
* const settings = useCassetteSettings()
* // Access with full type safety:
* settings.cassette.bodyColor
* settings.reels.animate
* settings.counter.style
* ```
*/
export function useCassetteSettings(): CassetteSettings {
const resolved = useResolvedThemeSettings('cassette', cassetteConfig)
return useMemo(() => parseSettings(resolved), [resolved])
}
/**
* Get default cassette settings (for use outside React)
*/
export function getDefaultCassetteSettings(): CassetteSettings {
const resolved = {} as ResolvedSettings
// Extract defaults from schema
function extractDefaults(settings: Record<string, unknown>, prefix = ''): void {
for (const [key, setting] of Object.entries(settings)) {
const s = setting as {
type: string
default?: unknown
settings?: Record<string, unknown>
}
const fullKey = prefix ? `${prefix}.${key}` : key
if (s.type === 'group' && s.settings) {
extractDefaults(s.settings, fullKey)
} else if ('default' in s) {
resolved[fullKey] = s.default
}
}
}
extractDefaults(cassetteConfig.settings)
return parseSettings(resolved)
}
export default useCassetteSettings
@@ -0,0 +1,198 @@
import React from 'react'
import { YStack, ZStack, getTokenValue } from 'tamagui'
import { Platform } from 'react-native'
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'
import { GestureDetector } from 'react-native-gesture-handler'
import CassetteBackground from './components/cassette-background'
import TapeDeck from './components/tape-deck'
import CassetteControls from './components/cassette-controls'
import TapeScrubber from './components/tape-scrubber'
import CassetteHeader from './components/cassette-header'
import CassetteFooter from './components/cassette-footer'
import { usePlayerGestures } from '../../shared/hooks/use-player-gestures'
import { usePrevious, useSkip } from '../../../../hooks/player/callbacks'
import useHapticFeedback from '../../../../hooks/use-haptic-feedback'
import Icon from '../../../Global/components/icon'
import type { PlayerThemeComponent, PlayerThemeProps } from '../types'
function CassettePlayer({
nowPlaying,
swipeX,
dimensions,
insets,
}: PlayerThemeProps): React.JSX.Element {
const skip = useSkip()
const previous = usePrevious()
const trigger = useHapticFeedback()
const isAndroid = Platform.OS === 'android'
const { width, height } = dimensions
const { top, bottom } = insets
const gesture = usePlayerGestures({
swipeX,
onSkipNext: () => skip(undefined),
onSkipPrevious: previous,
onHapticFeedback: (type) => trigger(type as Parameters<typeof trigger>[0]),
})
// Edge icon opacity styles for swipe feedback
const leftIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, -swipeX.value), [0, 40, 120], [0, 0.25, 1]),
}))
const rightIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, swipeX.value), [0, 40, 120], [0, 0.25, 1]),
}))
const mainContainerStyle = {
marginTop: isAndroid ? top : getTokenValue('$4'),
marginBottom: bottom + getTokenValue(isAndroid ? '$10' : '$12', 'space'),
}
return (
<ZStack width={width} height={height}>
<CassetteBackground width={width} height={height} />
{/* Swipe feedback icons */}
<Animated.View
pointerEvents='none'
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
>
<YStack flex={1} justifyContent='center'>
<Animated.View style={[{ position: 'absolute', left: 12 }, leftIconStyle]}>
<Icon name='skip-next' color='$warning' large />
</Animated.View>
<Animated.View style={[{ position: 'absolute', right: 12 }, rightIconStyle]}>
<Icon name='skip-previous' color='$warning' large />
</Animated.View>
</YStack>
</Animated.View>
{/* Gesture area */}
<GestureDetector gesture={gesture}>
<YStack
position='absolute'
top={height * 0.15}
left={width * 0.05}
right={width * 0.05}
height={height * 0.45}
zIndex={9998}
/>
</GestureDetector>
{/* Main content */}
<YStack flex={1} {...mainContainerStyle}>
<CassetteHeader />
<YStack flex={1} justifyContent='center' alignItems='center' gap='$4'>
<TapeDeck nowPlaying={nowPlaying} swipeX={swipeX} width={width} />
</YStack>
<YStack gap='$4' paddingBottom='$2'>
<TapeScrubber />
<CassetteControls />
<CassetteFooter />
</YStack>
</YStack>
</ZStack>
)
}
function CassettePreview({ width, height }: { width: number; height: number }): React.JSX.Element {
const cassetteWidth = width * 0.85
const cassetteHeight = cassetteWidth * 0.5
return (
<YStack
width={width}
height={height}
borderRadius='$2'
overflow='hidden'
justifyContent='center'
alignItems='center'
backgroundColor='#2C1810'
>
{/* Mini cassette representation */}
<YStack
width={cassetteWidth}
height={cassetteHeight}
backgroundColor='#D4C4B5'
borderRadius={6}
padding='$1'
alignItems='center'
justifyContent='center'
>
{/* Window area */}
<YStack
width={cassetteWidth * 0.9}
height={cassetteHeight * 0.5}
backgroundColor='#1A1A1A'
borderRadius={3}
flexDirection='row'
alignItems='center'
justifyContent='space-around'
paddingHorizontal='$1'
>
{/* Left reel */}
<YStack
width={cassetteHeight * 0.3}
height={cassetteHeight * 0.3}
borderRadius={cassetteHeight * 0.15}
backgroundColor='#3D2A1F'
borderWidth={2}
borderColor='#4A4A4A'
/>
{/* Right reel */}
<YStack
width={cassetteHeight * 0.3}
height={cassetteHeight * 0.3}
borderRadius={cassetteHeight * 0.15}
backgroundColor='#3D2A1F'
borderWidth={2}
borderColor='#4A4A4A'
/>
</YStack>
{/* Label area */}
<YStack
width={cassetteWidth * 0.8}
height={cassetteHeight * 0.25}
backgroundColor='#F5E6D3'
borderRadius={2}
marginTop='$0.5'
/>
</YStack>
{/* Mini controls representation */}
<YStack flexDirection='row' gap='$1' marginTop='$2'>
<YStack width={16} height={10} backgroundColor='#4A4A4A' borderRadius={2} />
<YStack width={20} height={10} backgroundColor='#5A3A2A' borderRadius={2} />
<YStack width={16} height={10} backgroundColor='#4A4A4A' borderRadius={2} />
</YStack>
</YStack>
)
}
const CassetteTheme: PlayerThemeComponent = {
Player: CassettePlayer,
Preview: CassettePreview,
metadata: {
id: 'cassette',
name: 'Cassette',
description: 'Retro tape deck with spinning reels',
icon: 'cassette',
experimental: true,
},
}
export default CassetteTheme
@@ -0,0 +1,356 @@
import type { ThemeConfigSchema } from '../schema'
/**
* Cassette Theme Configuration Schema
*
* This defines all customizable aspects of the cassette player theme.
* The settings UI is auto-generated from this schema.
*
* Enthusiasts can export their customizations as JSON and share them,
* or manually edit values for fine-grained control.
*/
const cassetteConfig: ThemeConfigSchema = {
version: 1,
meta: {
id: 'cassette',
name: 'Cassette',
description: 'Retro tape deck with spinning reels',
author: 'Jellify',
icon: 'cassette',
experimental: true,
},
settings: {
// ========================================
// Cassette Body
// ========================================
cassette: {
type: 'group',
label: 'Cassette Body',
description: 'Customize the cassette tape appearance',
settings: {
bodyColor: {
type: 'color',
label: 'Body Color',
description: 'Main cassette shell color',
default: '#D4C4B5',
},
bodyStyle: {
type: 'choice',
label: 'Body Style',
default: 'classic',
options: [
{
value: 'classic',
label: 'Classic Beige',
description: 'Traditional cassette look',
},
{
value: 'clear',
label: 'Clear Plastic',
description: 'Transparent shell',
},
{ value: 'black', label: 'Chrome Black', description: 'Sleek dark finish' },
{ value: 'white', label: 'Pure White', description: 'Clean minimal look' },
],
},
showScrews: {
type: 'toggle',
label: 'Show Screws',
description: 'Display decorative screw details',
default: true,
},
shadowIntensity: {
type: 'slider',
label: 'Shadow Intensity',
description: 'Depth of drop shadow',
default: 0.4,
min: 0,
max: 1,
step: 0.1,
},
},
},
// ========================================
// Tape Reels
// ========================================
reels: {
type: 'group',
label: 'Tape Reels',
description: 'Configure the spinning tape reels',
settings: {
tapeColor: {
type: 'color',
label: 'Tape Color',
description: 'Color of the magnetic tape',
default: '#3D2A1F',
},
animate: {
type: 'toggle',
label: 'Animate Reels',
description: 'Spin reels during playback',
default: true,
},
speed: {
type: 'slider',
label: 'Spin Speed',
description: 'How fast the reels rotate',
default: 1.0,
min: 0.5,
max: 2.0,
step: 0.1,
unit: 'x',
},
showTapeProgress: {
type: 'toggle',
label: 'Show Tape Progress',
description: 'Tape amount changes with playback position',
default: true,
},
},
},
// ========================================
// Label Area
// ========================================
label: {
type: 'group',
label: 'Label Area',
description: 'Customize the cassette label',
settings: {
style: {
type: 'choice',
label: 'Label Style',
default: 'album-art',
options: [
{
value: 'album-art',
label: 'Album Artwork',
description: 'Show album art as label',
},
{
value: 'vintage',
label: 'Vintage Paper',
description: 'Classic paper label look',
},
{
value: 'typed',
label: 'Typewriter',
description: 'Hand-typed label style',
},
{ value: 'minimal', label: 'Minimal', description: 'Clean, simple text' },
],
},
vintageOverlay: {
type: 'toggle',
label: 'Vintage Overlay',
description: 'Add aged paper effect to label',
default: true,
},
labelColor: {
type: 'color',
label: 'Label Background',
description: 'Background color (when not using album art)',
default: '#F5E6D3',
},
},
},
// ========================================
// Counter Display
// ========================================
counter: {
type: 'group',
label: 'Counter Display',
description: 'Configure the time/position display',
settings: {
style: {
type: 'choice',
label: 'Counter Style',
default: 'mechanical',
options: [
{
value: 'mechanical',
label: 'Mechanical',
description: 'Flip digit display',
},
{ value: 'digital', label: 'Digital LCD', description: 'Green LCD style' },
{ value: 'led', label: 'LED', description: 'Red LED segments' },
{ value: 'hidden', label: 'Hidden', description: 'Hide counter entirely' },
],
},
digitColor: {
type: 'color',
label: 'Digit Color',
description: 'Color of the counter digits',
default: '#E8B87D',
},
showDuration: {
type: 'toggle',
label: 'Show Duration',
description: 'Display total track length',
default: true,
},
},
},
// ========================================
// Controls
// ========================================
controls: {
type: 'group',
label: 'Transport Controls',
description: 'Playback button styling',
settings: {
style: {
type: 'choice',
label: 'Button Style',
default: 'raised',
options: [
{ value: 'raised', label: 'Raised', description: '3D tactile buttons' },
{ value: 'flat', label: 'Flat', description: 'Modern flat design' },
{ value: 'chrome', label: 'Chrome', description: 'Shiny metal buttons' },
],
},
buttonColor: {
type: 'color',
label: 'Button Color',
description: 'Primary button background',
default: '#5A3A2A',
},
haptics: {
type: 'toggle',
label: 'Haptic Feedback',
description: 'Vibrate on button press',
default: true,
},
},
},
// ========================================
// Background
// ========================================
background: {
type: 'group',
label: 'Background',
description: 'Player background settings',
settings: {
style: {
type: 'choice',
label: 'Background Style',
default: 'gradient',
options: [
{
value: 'gradient',
label: 'Warm Gradient',
description: 'Brown/amber gradient',
},
{
value: 'solid',
label: 'Solid Color',
description: 'Single color background',
},
{
value: 'wood',
label: 'Wood Grain',
description: 'Textured wood pattern',
},
{
value: 'album-blur',
label: 'Album Blur',
description: 'Blurred album artwork',
},
],
},
color: {
type: 'color',
label: 'Background Color',
description: 'Primary background color',
default: '#2C1810',
},
opacity: {
type: 'slider',
label: 'Overlay Opacity',
description: 'Darkness of background overlay',
default: 0.85,
min: 0.5,
max: 1,
step: 0.05,
},
},
},
},
// ========================================
// Presets
// ========================================
presets: [
{
id: 'classic',
name: 'Classic',
description: 'Traditional beige cassette',
values: {
'cassette.bodyColor': '#D4C4B5',
'cassette.bodyStyle': 'classic',
'reels.tapeColor': '#3D2A1F',
'label.style': 'album-art',
'counter.style': 'mechanical',
'background.style': 'gradient',
},
},
{
id: 'chrome-noir',
name: 'Chrome Noir',
description: 'Sleek black with chrome accents',
values: {
'cassette.bodyColor': '#1A1A1A',
'cassette.bodyStyle': 'black',
'reels.tapeColor': '#2D2D2D',
'label.style': 'minimal',
'counter.style': 'led',
'counter.digitColor': '#FF3333',
'controls.style': 'chrome',
'background.style': 'solid',
'background.color': '#0D0D0D',
},
},
{
id: 'clear-90s',
name: '90s Clear',
description: 'Transparent shell, visible mechanics',
values: {
'cassette.bodyColor': '#E8E8E8',
'cassette.bodyStyle': 'clear',
'cassette.shadowIntensity': 0.2,
'reels.tapeColor': '#4A3A2A',
'label.style': 'typed',
'counter.style': 'digital',
'counter.digitColor': '#33FF66',
'background.style': 'album-blur',
},
},
{
id: 'minimal-white',
name: 'Minimal White',
description: 'Clean, modern interpretation',
values: {
'cassette.bodyColor': '#FFFFFF',
'cassette.bodyStyle': 'white',
'cassette.showScrews': false,
'cassette.shadowIntensity': 0.15,
'reels.tapeColor': '#333333',
'label.style': 'minimal',
'label.vintageOverlay': false,
'counter.style': 'hidden',
'controls.style': 'flat',
'controls.buttonColor': '#333333',
'background.style': 'solid',
'background.color': '#F5F5F5',
},
},
],
}
export default cassetteConfig
@@ -2,10 +2,10 @@ import React from 'react'
import { getToken, useTheme, View, YStack, ZStack } from 'tamagui'
import { useWindowDimensions } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import { getBlurhashFromDto } from '../../../utils/parsing/blurhash'
import { getBlurhashFromDto } from '../../../../../utils/parsing/blurhash'
import { Blurhash } from 'react-native-blurhash'
import { useCurrentTrack } from '../../../stores/player/queue'
import useIsLightMode from '../../../hooks/use-is-light-mode'
import { useCurrentTrack } from '../../../../../stores/player/queue'
import useIsLightMode from '../../../../../hooks/use-is-light-mode'
export default function BlurredBackground(): React.JSX.Element {
const nowPlaying = useCurrentTrack()
@@ -1,11 +1,11 @@
import { State } from 'react-native-track-player'
import { Circle, Spinner, View } from 'tamagui'
import IconButton from '../../../components/Global/helpers/icon-button'
import IconButton from '../../../../Global/helpers/icon-button'
import { isUndefined } from 'lodash'
import { useTogglePlayback } from '../../../hooks/player/callbacks'
import { usePlaybackState } from '../../../hooks/player/queries'
import { useTogglePlayback } from '../../../../../hooks/player/callbacks'
import { usePlaybackState } from '../../../../../hooks/player/queries'
import React from 'react'
import Icon from '../../Global/components/icon'
import Icon from '../../../../Global/components/icon'
export default function PlayPauseButton({
size,
@@ -1,15 +1,15 @@
import React from 'react'
import { Spacer, XStack, getToken } from 'tamagui'
import PlayPauseButton from './buttons'
import Icon from '../../Global/components/icon'
import Icon from '../../../../Global/components/icon'
import { RepeatMode } from 'react-native-track-player'
import {
usePrevious,
useSkip,
useToggleRepeatMode,
useToggleShuffle,
} from '../../../hooks/player/callbacks'
import { useRepeatModeStoreValue, useShuffle } from '../../../stores/player/queue'
} from '../../../../../hooks/player/callbacks'
import { useRepeatModeStoreValue, useShuffle } from '../../../../../stores/player/queue'
export default function Controls(): React.JSX.Element {
const previous = usePrevious()
@@ -1,16 +1,16 @@
import { Spacer, useTheme, XStack } from 'tamagui'
import Icon from '../../Global/components/icon'
import Icon from '../../../../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
import { PlayerParamList } from '../../../screens/Player/types'
import { PlayerParamList } from '../../../../../screens/Player/types'
import { CastButton, MediaHlsSegmentFormat, useRemoteMediaClient } from 'react-native-google-cast'
import { useEffect } from 'react'
import usePlayerEngineStore from '../../../stores/player/engine'
import useRawLyrics from '../../../api/queries/lyrics'
import usePlayerEngineStore from '../../../../../stores/player/engine'
import useRawLyrics from '../../../../../api/queries/lyrics'
import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useCurrentTrack } from '../../../../../stores/player/queue'
export default function Footer(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
@@ -1,7 +1,7 @@
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { Text } from '../../../../Global/helpers/text'
import React from 'react'
import ItemImage from '../../Global/components/image'
import ItemImage from '../../../../Global/components/image'
import Animated, {
SnappySpringConfig,
useAnimatedStyle,
@@ -11,10 +11,10 @@ import Animated, {
} from 'react-native-reanimated'
import { LayoutChangeEvent } from 'react-native'
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
import navigationRef from '../../../../navigation'
import { useCurrentTrack, useQueueRef } from '../../../stores/player/queue'
import navigationRef from '../../../../../../navigation'
import { useCurrentTrack, useQueueRef } from '../../../../../stores/player/queue'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../component.config'
import { TextTickerConfig } from '../../../component.config'
export default function PlayerHeader(): React.JSX.Element {
const queueRef = useQueueRef()
@@ -1,11 +1,11 @@
import { PlayerParamList } from '../../../screens/Player/types'
import { PlayerParamList } from '../../../../../screens/Player/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text, useWindowDimensions, View, YStack, ZStack, useTheme, XStack, Spacer } from 'tamagui'
import BlurredBackground from './blurred-background'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useProgress } from '../../../hooks/player/queries'
import { useSeekTo } from '../../../hooks/player/callbacks'
import { UPDATE_INTERVAL } from '../../../configs/player.config'
import { useProgress } from '../../../../../hooks/player/queries'
import { useSeekTo } from '../../../../../hooks/player/callbacks'
import { UPDATE_INTERVAL } from '../../../../../configs/player.config'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import Animated, {
useSharedValue,
@@ -18,8 +18,8 @@ import Animated, {
} from 'react-native-reanimated'
import { FlatList, ListRenderItem } from 'react-native'
import { trigger } from 'react-native-haptic-feedback'
import Icon from '../../Global/components/icon'
import useRawLyrics from '../../../api/queries/lyrics'
import Icon from '../../../../Global/components/icon'
import useRawLyrics from '../../../../../api/queries/lyrics'
interface LyricLine {
Text: string
@@ -1,9 +1,9 @@
import { Spacer, Square } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import navigationRef from '../../../../navigation'
import { parseBitrateFromTranscodingUrl } from '../../../utils/parsing/url'
import { Text } from '../../../../Global/helpers/text'
import navigationRef from '../../../../../../navigation'
import { parseBitrateFromTranscodingUrl } from '../../../../../utils/parsing/url'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import { SourceType } from '../../../types/JellifyTrack'
import { SourceType } from '../../../../../types/JellifyTrack'
interface QualityBadgeProps {
item: BaseItemDto
@@ -1,17 +1,17 @@
import React, { useEffect, useState, useRef } from 'react'
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { HorizontalSlider } from '../../../../Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { Spacer, XStack, YStack } from 'tamagui'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { useSeekTo } from '../../../hooks/player/callbacks'
import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../configs/player.config'
import { ProgressMultiplier } from '../component.config'
import { useProgress } from '../../../hooks/player/queries'
import { useSeekTo } from '../../../../../hooks/player/callbacks'
import { RunTimeSeconds } from '../../../../Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../../../configs/player.config'
import { ProgressMultiplier } from '../../../component.config'
import { useProgress } from '../../../../../hooks/player/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/settings/player'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useDisplayAudioQualityBadge } from '../../../../../stores/settings/player'
import useHapticFeedback from '../../../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../../../stores/player/queue'
// Create a simple pan gesture
const scrubGesture = Gesture.Pan()
@@ -1,25 +1,25 @@
import TextTicker from 'react-native-text-ticker'
import { getToken, XStack, YStack } from 'tamagui'
import { TextTickerConfig } from '../component.config'
import { Text } from '../../Global/helpers/text'
import { TextTickerConfig } from '../../../component.config'
import { Text } from '../../../../Global/helpers/text'
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchItem } from '../../../api/queries/item'
import FavoriteButton from '../../Global/components/favorite-button'
import { QueryKeys } from '../../../enums/query-keys'
import navigationRef from '../../../../navigation'
import Icon from '../../Global/components/icon'
import { getItemName } from '../../../utils/formatting/item-names'
import { fetchItem } from '../../../../../api/queries/item'
import FavoriteButton from '../../../../Global/components/favorite-button'
import { QueryKeys } from '../../../../../enums/query-keys'
import navigationRef from '../../../../../../navigation'
import Icon from '../../../../Global/components/icon'
import { getItemName } from '../../../../../utils/formatting/item-names'
import { CommonActions } from '@react-navigation/native'
import { Gesture } from 'react-native-gesture-handler'
import { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
import type { SharedValue } from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../../hooks/player/callbacks'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
import formatArtistNames from '../../../utils/formatting/artist-names'
import { usePrevious, useSkip } from '../../../../../hooks/player/callbacks'
import useHapticFeedback from '../../../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../../../stores/player/queue'
import { useApi } from '../../../../../stores'
import formatArtistNames from '../../../../../utils/formatting/artist-names'
type SongInfoProps = {
// Shared animated value coming from Player to drive overlay icons
@@ -0,0 +1,195 @@
import React from 'react'
import { YStack, ZStack, View, getTokenValue } from 'tamagui'
import { Platform } from 'react-native'
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'
import { GestureDetector } from 'react-native-gesture-handler'
import Scrubber from './components/scrubber'
import Controls from './components/controls'
import Footer from './components/footer'
import BlurredBackground from './components/blurred-background'
import PlayerHeader from './components/header'
import SongInfo from './components/song-info'
import { usePlayerGestures } from '../../shared/hooks/use-player-gestures'
import { usePrevious, useSkip } from '../../../../hooks/player/callbacks'
import useHapticFeedback from '../../../../hooks/use-haptic-feedback'
import Icon from '../../../Global/components/icon'
import type { PlayerThemeComponent, PlayerThemeProps } from '../types'
function DefaultPlayer({ swipeX, dimensions, insets }: PlayerThemeProps): React.JSX.Element {
const skip = useSkip()
const previous = usePrevious()
const trigger = useHapticFeedback()
const isAndroid = Platform.OS === 'android'
const { width, height } = dimensions
const { top, bottom } = insets
const gesture = usePlayerGestures({
swipeX,
onSkipNext: () => skip(undefined),
onSkipPrevious: previous,
onHapticFeedback: (type) => trigger(type as Parameters<typeof trigger>[0]),
})
// Edge icon opacity styles
const leftIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, -swipeX.value), [0, 40, 120], [0, 0.25, 1]),
}))
const rightIconStyle = useAnimatedStyle(() => ({
opacity: interpolate(Math.max(0, swipeX.value), [0, 40, 120], [0, 0.25, 1]),
}))
const mainContainerStyle = {
marginTop: isAndroid ? top : getTokenValue('$4'),
marginBottom: bottom + getTokenValue(isAndroid ? '$10' : '$12', 'space'),
}
return (
<ZStack width={width} height={height}>
<BlurredBackground />
{/* Swipe feedback icons (topmost overlay) */}
<Animated.View
pointerEvents='none'
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
}}
>
<YStack flex={1} justifyContent='center'>
<Animated.View style={[{ position: 'absolute', left: 12 }, leftIconStyle]}>
<Icon name='skip-next' color='$primary' large />
</Animated.View>
<Animated.View style={[{ position: 'absolute', right: 12 }, rightIconStyle]}>
<Icon name='skip-previous' color='$primary' large />
</Animated.View>
</YStack>
</Animated.View>
{/* Central large swipe area overlay (captures swipe like big album art) */}
<GestureDetector gesture={gesture}>
<View
style={{
position: 'absolute',
top: height * 0.18,
left: width * 0.06,
right: width * 0.06,
height: height * 0.36,
zIndex: 9998,
}}
/>
</GestureDetector>
<YStack
justifyContent='center'
flex={1}
marginHorizontal={'$5'}
{...mainContainerStyle}
>
<PlayerHeader />
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
<SongInfo />
<Scrubber />
<Controls />
<Footer />
</YStack>
</YStack>
</ZStack>
)
}
function DefaultPreview({ width, height }: { width: number; height: number }): React.JSX.Element {
const scale = Math.min(width / 390, height / 844)
return (
<YStack
width={width}
height={height}
backgroundColor='$background'
borderRadius='$2'
overflow='hidden'
padding='$2'
justifyContent='space-between'
>
{/* Mini album art representation */}
<YStack alignItems='center' flex={1} justifyContent='center'>
<YStack
width={width * 0.7}
height={width * 0.7}
backgroundColor='$background50'
borderRadius='$3'
/>
</YStack>
{/* Mini controls representation */}
<YStack gap='$1' paddingBottom='$1'>
{/* Song info placeholder */}
<YStack gap='$0.5' paddingHorizontal='$1'>
<YStack
width='60%'
height={10 * scale}
backgroundColor='$borderColor'
borderRadius='$1'
/>
<YStack
width='40%'
height={8 * scale}
backgroundColor='$background50'
borderRadius='$1'
/>
</YStack>
{/* Scrubber placeholder */}
<YStack
height={4 * scale}
backgroundColor='$background50'
borderRadius='$1'
marginHorizontal='$1'
/>
{/* Controls placeholder */}
<YStack flexDirection='row' justifyContent='center' gap='$2' paddingTop='$1'>
<YStack
width={20 * scale}
height={20 * scale}
backgroundColor='$background50'
borderRadius={10 * scale}
/>
<YStack
width={28 * scale}
height={28 * scale}
backgroundColor='$primary'
borderRadius={14 * scale}
/>
<YStack
width={20 * scale}
height={20 * scale}
backgroundColor='$background50'
borderRadius={10 * scale}
/>
</YStack>
</YStack>
</YStack>
)
}
const DefaultTheme: PlayerThemeComponent = {
Player: DefaultPlayer,
Preview: DefaultPreview,
metadata: {
id: 'default',
name: 'Modern',
description: 'Clean, modern player with album artwork focus',
icon: 'play-circle-outline',
},
}
export default DefaultTheme
+65
View File
@@ -0,0 +1,65 @@
import type { PlayerThemeId } from '../../../stores/settings/player-theme'
import type { PlayerThemeComponent, PlayerThemeMetadata } from './types'
type ThemeLoader = () => Promise<{ default: PlayerThemeComponent }>
const themeLoaders: Record<PlayerThemeId, ThemeLoader> = {
default: () => import('./default'),
cassette: () => import('./cassette'),
}
/**
* Theme registry - maintains loaded themes and provides access
*/
class ThemeRegistry {
private cache: Map<PlayerThemeId, PlayerThemeComponent> = new Map()
async getTheme(id: PlayerThemeId): Promise<PlayerThemeComponent> {
const cached = this.cache.get(id)
if (cached) {
return cached
}
const loader = themeLoaders[id]
if (!loader) {
throw new Error(`Unknown theme: ${id}`)
}
const module = await loader()
const theme = module.default
this.cache.set(id, theme)
return theme
}
/** Get all available theme IDs */
getAvailableThemes(): PlayerThemeId[] {
return Object.keys(themeLoaders) as PlayerThemeId[]
}
/** Preload a theme (useful for settings preview) */
preloadTheme(id: PlayerThemeId): void {
this.getTheme(id).catch(console.error)
}
}
export const themeRegistry = new ThemeRegistry()
/**
* Get metadata for all themes (sync, for settings UI)
* This avoids loading full theme bundles just to show the list
*/
export const THEME_METADATA: Record<PlayerThemeId, PlayerThemeMetadata> = {
default: {
id: 'default',
name: 'Modern',
description: 'Clean, modern player with album artwork focus',
icon: 'play-circle-outline',
},
cassette: {
id: 'cassette',
name: 'Cassette',
description: 'Retro tape deck with spinning reels',
icon: 'cassette',
experimental: true,
},
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Theme Customization Schema System
*
* Each theme can define a JSON schema that describes its customizable properties.
* The settings UI is auto-generated from this schema, and user preferences are
* stored and applied at runtime.
*
* Enthusiasts can also edit the JSON directly for fine-grained control.
*/
// ============================================================================
// Schema Definition Types
// ============================================================================
export interface ColorSetting {
type: 'color'
label: string
description?: string
default: string
}
export interface ToggleSetting {
type: 'toggle'
label: string
description?: string
default: boolean
}
export interface SliderSetting {
type: 'slider'
label: string
description?: string
default: number
min: number
max: number
step: number
unit?: string
}
export interface ChoiceSetting {
type: 'choice'
label: string
description?: string
default: string
options: Array<{
value: string
label: string
description?: string
}>
}
export interface GroupSetting {
type: 'group'
label: string
description?: string
collapsed?: boolean
settings: Record<string, SettingDefinition>
}
export type SettingDefinition =
| ColorSetting
| ToggleSetting
| SliderSetting
| ChoiceSetting
| GroupSetting
// ============================================================================
// Theme Configuration Schema
// ============================================================================
export interface ThemeConfigSchema {
/** Schema version for future compatibility */
$schema?: string
version: number
/** Theme metadata */
meta: {
id: string
name: string
description: string
author?: string
icon: string
experimental?: boolean
}
/** Customizable settings organized by category */
settings: Record<string, SettingDefinition>
/** Preset configurations users can quickly apply */
presets?: Array<{
id: string
name: string
description?: string
values: Record<string, unknown>
}>
}
// ============================================================================
// User Customization Storage Types
// ============================================================================
export interface ThemeCustomization {
/** Theme ID this customization applies to */
themeId: string
/** Timestamp of last modification */
updatedAt: number
/** User's custom values (keys match schema paths) */
values: Record<string, unknown>
/** Currently applied preset ID, if any */
activePreset?: string
}
// ============================================================================
// Runtime Resolved Values
// ============================================================================
export type ResolvedSettings = Record<string, unknown>
/**
* Resolves the final settings by merging:
* 1. Schema defaults
* 2. Preset values (if active)
* 3. User customizations
*/
export function resolveSettings(
schema: ThemeConfigSchema,
customization?: ThemeCustomization,
): ResolvedSettings {
const resolved: ResolvedSettings = {}
// Extract defaults from schema
function extractDefaults(settings: Record<string, SettingDefinition>, prefix = ''): void {
for (const [key, setting] of Object.entries(settings)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (setting.type === 'group') {
extractDefaults(setting.settings, fullKey)
} else {
resolved[fullKey] = setting.default
}
}
}
extractDefaults(schema.settings)
// Apply preset if active
if (customization?.activePreset && schema.presets) {
const preset = schema.presets.find((p) => p.id === customization.activePreset)
if (preset) {
Object.assign(resolved, preset.values)
}
}
// Apply user customizations (highest priority)
if (customization?.values) {
Object.assign(resolved, customization.values)
}
return resolved
}
/**
* Get a specific setting value with type safety
*/
export function getSetting<T>(settings: ResolvedSettings, key: string, fallback: T): T {
const value = settings[key]
return value !== undefined ? (value as T) : fallback
}
// ============================================================================
// Schema Validation
// ============================================================================
export function validateCustomization(
schema: ThemeConfigSchema,
values: Record<string, unknown>,
): { valid: boolean; errors: string[] } {
const errors: string[] = []
function validateSetting(key: string, value: unknown, definition: SettingDefinition): void {
switch (definition.type) {
case 'color':
if (typeof value !== 'string' || !/^#[0-9A-Fa-f]{6,8}$/.test(value)) {
errors.push(`${key}: Invalid color format (expected #RRGGBB or #RRGGBBAA)`)
}
break
case 'toggle':
if (typeof value !== 'boolean') {
errors.push(`${key}: Expected boolean`)
}
break
case 'slider':
if (typeof value !== 'number') {
errors.push(`${key}: Expected number`)
} else if (value < definition.min || value > definition.max) {
errors.push(`${key}: Value out of range (${definition.min}-${definition.max})`)
}
break
case 'choice':
if (!definition.options.some((opt) => opt.value === value)) {
errors.push(`${key}: Invalid choice`)
}
break
case 'group':
// Groups don't have direct values
break
}
}
function findDefinition(path: string): SettingDefinition | undefined {
const parts = path.split('.')
let current: Record<string, SettingDefinition> = schema.settings
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const setting = current[part]
if (!setting) return undefined
if (i === parts.length - 1) {
return setting
}
if (setting.type === 'group') {
current = setting.settings
} else {
return undefined
}
}
return undefined
}
for (const [key, value] of Object.entries(values)) {
const definition = findDefinition(key)
if (!definition) {
errors.push(`${key}: Unknown setting`)
} else {
validateSetting(key, value, definition)
}
}
return { valid: errors.length === 0, errors }
}
// ============================================================================
// Schema Export/Import
// ============================================================================
export interface ExportedThemeCustomization {
$type: 'jellify-theme-customization'
$version: 1
themeId: string
themeName: string
exportedAt: string
values: Record<string, unknown>
}
export function exportCustomization(
schema: ThemeConfigSchema,
customization: ThemeCustomization,
): ExportedThemeCustomization {
return {
$type: 'jellify-theme-customization',
$version: 1,
themeId: customization.themeId,
themeName: schema.meta.name,
exportedAt: new Date().toISOString(),
values: customization.values,
}
}
export function importCustomization(
data: unknown,
):
| { success: true; customization: Partial<ThemeCustomization> }
| { success: false; error: string } {
if (!data || typeof data !== 'object') {
return { success: false, error: 'Invalid data format' }
}
const obj = data as Record<string, unknown>
if (obj.$type !== 'jellify-theme-customization') {
return { success: false, error: 'Not a valid theme customization file' }
}
if (obj.$version !== 1) {
return { success: false, error: `Unsupported version: ${obj.$version}` }
}
if (typeof obj.themeId !== 'string' || typeof obj.values !== 'object') {
return { success: false, error: 'Missing required fields' }
}
return {
success: true,
customization: {
themeId: obj.themeId,
values: obj.values as Record<string, unknown>,
updatedAt: Date.now(),
},
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { SharedValue } from 'react-native-reanimated'
import type JellifyTrack from '../../../types/JellifyTrack'
import type { PlayerThemeId } from '../../../stores/settings/player-theme'
/**
* Metadata for a player theme, used in settings UI
*/
export interface PlayerThemeMetadata {
id: PlayerThemeId
name: string
description: string
/** Icon name from material-design-icons */
icon: string
/** Whether this theme is experimental/beta */
experimental?: boolean
}
/**
* Props passed to all theme components
*/
export interface PlayerThemeProps {
/** Current track data */
nowPlaying: JellifyTrack
/** Shared animated value for horizontal swipe gestures */
swipeX: SharedValue<number>
/** Dimensions from useWindowDimensions */
dimensions: { width: number; height: number }
/** Safe area insets */
insets: { top: number; bottom: number; left: number; right: number }
}
/**
* Interface that each theme must implement
*/
export interface PlayerThemeComponent {
/** The main player component */
Player: React.ComponentType<PlayerThemeProps>
/** Static preview for settings (no playback logic) */
Preview: React.ComponentType<{ width: number; height: number }>
/** Theme metadata */
metadata: PlayerThemeMetadata
}
@@ -33,6 +33,12 @@ export default function VerticalSettings(): React.JSX.Element {
route='Appearance'
iconColor='$primary'
/>
<SettingsNavRow
title='Player Style'
icon='cassette'
route='PlayerTheme'
iconColor='$warning'
/>
<SettingsNavRow
title='Gestures'
icon='gesture-swipe'
+1 -1
View File
@@ -4,7 +4,7 @@ import Queue from '../../components/Player/queue'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import MultipleArtistsSheet from '../Context/multiple-artists'
import { PlayerParamList } from './types'
import Lyrics from '../../components/Player/components/lyrics'
import Lyrics from '../../components/Player/themes/default/components/lyrics'
import usePlayerDisplayStore from '../../stores/player/display'
const PlayerStack = createNativeStackNavigator<PlayerParamList>()
+10
View File
@@ -10,6 +10,7 @@ import GesturesScreen from './gestures'
import PlaybackScreen from './playback'
import PrivacyDeveloperScreen from './privacy-developer'
import AboutScreen from './about'
import PlayerThemeScreen from './player-theme'
import { SettingsStackParamList } from './types'
export const SettingsStack = createNativeStackNavigator<SettingsStackParamList>()
@@ -110,6 +111,15 @@ export default function SettingsScreen(): React.JSX.Element {
headerShown: true,
}}
/>
<SettingsStack.Screen
name='PlayerTheme'
component={PlayerThemeScreen}
options={{
title: 'Player Style',
animation: 'slide_from_right',
headerShown: true,
}}
/>
</SettingsStack.Navigator>
)
}
+131
View File
@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react'
import { YStack, XStack, SizableText, ScrollView, Card, Spinner } from 'tamagui'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useWindowDimensions } from 'react-native'
import Icon from '../../components/Global/components/icon'
import { usePlayerTheme, PlayerThemeId } from '../../stores/settings/player-theme'
import { THEME_METADATA, themeRegistry } from '../../components/Player/themes'
import type { PlayerThemeComponent } from '../../components/Player/themes/types'
export default function PlayerThemeScreen(): React.JSX.Element {
const { bottom } = useSafeAreaInsets()
const { width } = useWindowDimensions()
const [playerTheme, setPlayerTheme] = usePlayerTheme()
// Preview dimensions (scaled down for two columns)
const previewWidth = (width - 48) / 2
const previewHeight = previewWidth * 1.6
const themeIds = Object.keys(THEME_METADATA) as PlayerThemeId[]
return (
<YStack flex={1} backgroundColor='$background'>
<ScrollView
contentContainerStyle={{ paddingBottom: Math.max(bottom, 16) + 16 }}
showsVerticalScrollIndicator={false}
>
<YStack padding='$4' gap='$6'>
<YStack gap='$2'>
<SizableText size='$4' fontWeight='600' color='$borderColor'>
Player Style
</SizableText>
<SizableText size='$2' color='$borderColor'>
Choose how the full-screen player looks
</SizableText>
</YStack>
<XStack flexWrap='wrap' gap='$3' justifyContent='space-between'>
{themeIds.map((themeId) => (
<ThemePreviewCard
key={themeId}
themeId={themeId}
isSelected={playerTheme === themeId}
onSelect={() => setPlayerTheme(themeId)}
previewWidth={previewWidth}
previewHeight={previewHeight}
/>
))}
</XStack>
</YStack>
</ScrollView>
</YStack>
)
}
interface ThemePreviewCardProps {
themeId: PlayerThemeId
isSelected: boolean
onSelect: () => void
previewWidth: number
previewHeight: number
}
function ThemePreviewCard({
themeId,
isSelected,
onSelect,
previewWidth,
previewHeight,
}: ThemePreviewCardProps): React.JSX.Element {
const [PreviewComponent, setPreviewComponent] = useState<React.ComponentType<{
width: number
height: number
}> | null>(null)
const [isLoading, setIsLoading] = useState(true)
const metadata = THEME_METADATA[themeId]
useEffect(() => {
themeRegistry
.getTheme(themeId)
.then((theme: PlayerThemeComponent) => setPreviewComponent(() => theme.Preview))
.catch(console.error)
.finally(() => setIsLoading(false))
}, [themeId])
return (
<Card
onPress={onSelect}
pressStyle={{ scale: 0.97 }}
animation='quick'
borderWidth='$1'
borderColor={isSelected ? '$primary' : '$borderColor'}
backgroundColor={isSelected ? '$background25' : '$background'}
borderRadius='$4'
overflow='hidden'
width={previewWidth}
>
{/* Preview area */}
<YStack
height={previewHeight}
backgroundColor='$background50'
justifyContent='center'
alignItems='center'
>
{isLoading ? (
<Spinner color='$primary' />
) : PreviewComponent ? (
<PreviewComponent width={previewWidth - 8} height={previewHeight - 8} />
) : null}
</YStack>
{/* Info area */}
<YStack padding='$2' gap='$1'>
<XStack alignItems='center' gap='$2'>
<Icon small name={metadata.icon} color={isSelected ? '$primary' : '$color'} />
<SizableText size='$3' fontWeight='600' flex={1}>
{metadata.name}
</SizableText>
{metadata.experimental && (
<SizableText size='$1' color='$warning'>
BETA
</SizableText>
)}
{isSelected && <Icon small name='check-circle' color='$primary' />}
</XStack>
<SizableText size='$2' color='$borderColor' numberOfLines={2}>
{metadata.description}
</SizableText>
</YStack>
</Card>
)
}
+1
View File
@@ -11,6 +11,7 @@ export type SettingsStackParamList = {
Playback: undefined
PrivacyDeveloper: undefined
About: undefined
PlayerTheme: undefined
}
export type SettingsProps = NativeStackScreenProps<SettingsStackParamList, 'Settings'>
+31
View File
@@ -0,0 +1,31 @@
import { mmkvStateStorage } from '../../constants/storage'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
export type PlayerThemeId = 'default' | 'cassette'
type PlayerThemeStore = {
playerTheme: PlayerThemeId
setPlayerTheme: (theme: PlayerThemeId) => void
}
export const usePlayerThemeStore = create<PlayerThemeStore>()(
devtools(
persist(
(set): PlayerThemeStore => ({
playerTheme: 'default',
setPlayerTheme: (playerTheme: PlayerThemeId) => set({ playerTheme }),
}),
{
name: 'player-theme-storage',
storage: createJSONStorage(() => mmkvStateStorage),
},
),
),
)
export const usePlayerTheme: () => [PlayerThemeId, (theme: PlayerThemeId) => void] = () => {
const playerTheme = usePlayerThemeStore((state) => state.playerTheme)
const setPlayerTheme = usePlayerThemeStore((state) => state.setPlayerTheme)
return [playerTheme, setPlayerTheme]
}
+236
View File
@@ -0,0 +1,236 @@
import { create } from 'zustand'
import { devtools, persist, createJSONStorage } from 'zustand/middleware'
import { mmkvStateStorage } from '../../constants/storage'
import type {
ThemeCustomization,
ResolvedSettings,
ThemeConfigSchema,
} from '../../components/Player/themes/schema'
import { resolveSettings } from '../../components/Player/themes/schema'
import type { PlayerThemeId } from './player-theme'
// ============================================================================
// Store Types
// ============================================================================
interface ThemeCustomizationState {
/** Customizations per theme, keyed by theme ID */
customizations: Record<string, ThemeCustomization>
/** Get customization for a specific theme */
getCustomization: (themeId: PlayerThemeId) => ThemeCustomization | undefined
/** Set a single setting value */
setSetting: (themeId: PlayerThemeId, key: string, value: unknown) => void
/** Set multiple settings at once */
setSettings: (themeId: PlayerThemeId, values: Record<string, unknown>) => void
/** Apply a preset by ID */
applyPreset: (themeId: PlayerThemeId, presetId: string) => void
/** Clear preset (keeps custom values) */
clearPreset: (themeId: PlayerThemeId) => void
/** Reset all customizations for a theme */
resetTheme: (themeId: PlayerThemeId) => void
/** Reset all customizations for all themes */
resetAll: () => void
/** Import customization from JSON */
importCustomization: (themeId: PlayerThemeId, values: Record<string, unknown>) => void
/** Export customization as plain object */
exportCustomization: (themeId: PlayerThemeId) => Record<string, unknown> | undefined
}
// ============================================================================
// Store Implementation
// ============================================================================
export const useThemeCustomizationStore = create<ThemeCustomizationState>()(
devtools(
persist(
(set, get) => ({
customizations: {},
getCustomization: (themeId) => {
return get().customizations[themeId]
},
setSetting: (themeId, key, value) => {
set((state) => {
const existing = state.customizations[themeId] || {
themeId,
values: {},
updatedAt: Date.now(),
}
return {
customizations: {
...state.customizations,
[themeId]: {
...existing,
values: {
...existing.values,
[key]: value,
},
updatedAt: Date.now(),
},
},
}
})
},
setSettings: (themeId, values) => {
set((state) => {
const existing = state.customizations[themeId] || {
themeId,
values: {},
updatedAt: Date.now(),
}
return {
customizations: {
...state.customizations,
[themeId]: {
...existing,
values: {
...existing.values,
...values,
},
updatedAt: Date.now(),
},
},
}
})
},
applyPreset: (themeId, presetId) => {
set((state) => {
const existing = state.customizations[themeId] || {
themeId,
values: {},
updatedAt: Date.now(),
}
return {
customizations: {
...state.customizations,
[themeId]: {
...existing,
activePreset: presetId,
updatedAt: Date.now(),
},
},
}
})
},
clearPreset: (themeId) => {
set((state) => {
const existing = state.customizations[themeId]
if (!existing) return state
const { activePreset, ...rest } = existing
return {
customizations: {
...state.customizations,
[themeId]: {
...rest,
updatedAt: Date.now(),
},
},
}
})
},
resetTheme: (themeId) => {
set((state) => {
const { [themeId]: removed, ...rest } = state.customizations
return { customizations: rest }
})
},
resetAll: () => {
set({ customizations: {} })
},
importCustomization: (themeId, values) => {
set((state) => ({
customizations: {
...state.customizations,
[themeId]: {
themeId,
values,
updatedAt: Date.now(),
},
},
}))
},
exportCustomization: (themeId) => {
const customization = get().customizations[themeId]
return customization?.values
},
}),
{
name: 'theme-customization-storage',
storage: createJSONStorage(() => mmkvStateStorage),
},
),
{ name: 'theme-customization' },
),
)
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook to get resolved settings for a theme, merging defaults + preset + customizations
*/
export function useResolvedThemeSettings(
themeId: PlayerThemeId,
schema: ThemeConfigSchema,
): ResolvedSettings {
const customization = useThemeCustomizationStore((state) => state.customizations[themeId])
return resolveSettings(schema, customization)
}
/**
* Hook to get a specific setting value with type safety
*/
export function useThemeSetting<T>(
themeId: PlayerThemeId,
schema: ThemeConfigSchema,
key: string,
fallback: T,
): T {
const settings = useResolvedThemeSettings(themeId, schema)
const value = settings[key]
return value !== undefined ? (value as T) : fallback
}
/**
* Hook to get setters for theme customization
*/
export function useThemeCustomizationActions() {
return useThemeCustomizationStore((state) => ({
setSetting: state.setSetting,
setSettings: state.setSettings,
applyPreset: state.applyPreset,
clearPreset: state.clearPreset,
resetTheme: state.resetTheme,
importCustomization: state.importCustomization,
exportCustomization: state.exportCustomization,
}))
}
/**
* Hook to get the active preset for a theme
*/
export function useActivePreset(themeId: PlayerThemeId): string | undefined {
return useThemeCustomizationStore((state) => state.customizations[themeId]?.activePreset)
}