mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-24 03:49:11 -05:00
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:
+56
-144
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -11,6 +11,7 @@ export type SettingsStackParamList = {
|
||||
Playback: undefined
|
||||
PrivacyDeveloper: undefined
|
||||
About: undefined
|
||||
PlayerTheme: undefined
|
||||
}
|
||||
|
||||
export type SettingsProps = NativeStackScreenProps<SettingsStackParamList, 'Settings'>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user