mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-21 13:30:11 -06:00
Enhance player controls with expressive animations and improved UI elements
This commit is contained in:
@@ -1,66 +1,101 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import React, { useCallback } from 'react'
|
||||
import Icon from './icon'
|
||||
import Animated, { BounceIn, FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import React from 'react'
|
||||
import Animated, {
|
||||
BounceIn,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
|
||||
import { useIsFavorite } from '../../../api/queries/user-data'
|
||||
import { getTokenValue, Spinner } from 'tamagui'
|
||||
import { Spinner, useTheme } from 'tamagui'
|
||||
import { Pressable, StyleSheet } from 'react-native'
|
||||
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
item: BaseItemDto
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* M3 Expressive FavoriteButton
|
||||
*
|
||||
* - 44px circular container
|
||||
* - Filled with primary color when favorited
|
||||
* - Outlined with primary border when not favorited
|
||||
* - Spring scale animation on press
|
||||
*/
|
||||
export default function FavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
|
||||
const { data: isFavorite, isPending } = useIsFavorite(item)
|
||||
const theme = useTheme()
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
return isPending ? (
|
||||
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
|
||||
) : isFavorite ? (
|
||||
<AddFavoriteButton item={item} onToggle={onToggle} />
|
||||
) : (
|
||||
<RemoveFavoriteButton item={item} onToggle={onToggle} />
|
||||
)
|
||||
}
|
||||
const addFavorite = useAddFavorite()
|
||||
const removeFavorite = useRemoveFavorite()
|
||||
|
||||
function AddFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
|
||||
const { mutate, isPending } = useRemoveFavorite()
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}))
|
||||
|
||||
return isPending ? (
|
||||
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
|
||||
) : (
|
||||
<Animated.View entering={BounceIn} exiting={FadeOut}>
|
||||
<Icon
|
||||
name={'heart'}
|
||||
color={'$primary'}
|
||||
onPress={() =>
|
||||
mutate({
|
||||
item,
|
||||
onToggle,
|
||||
})
|
||||
}
|
||||
/>
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.9, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
const handlePress = () => {
|
||||
if (isFavorite) {
|
||||
removeFavorite.mutate({ item, onToggle })
|
||||
} else {
|
||||
addFavorite.mutate({ item, onToggle })
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending || addFavorite.isPending || removeFavorite.isPending) {
|
||||
return (
|
||||
<Animated.View style={[styles.container, { borderColor: theme.primary.val }]}>
|
||||
<Spinner color={theme.primary.val} size='small' />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={isFavorite ? BounceIn : FadeIn}
|
||||
exiting={FadeOut}
|
||||
style={animatedStyle}
|
||||
>
|
||||
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={handlePress}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
isFavorite
|
||||
? { backgroundColor: theme.primary.val, borderColor: theme.primary.val }
|
||||
: { backgroundColor: 'transparent', borderColor: theme.primary.val },
|
||||
]}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={22}
|
||||
color={isFavorite ? theme.background.val : theme.primary.val}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
|
||||
const { mutate, isPending } = useAddFavorite()
|
||||
|
||||
return isPending ? (
|
||||
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
|
||||
) : (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Icon
|
||||
name={'heart-outline'}
|
||||
color={'$primary'}
|
||||
onPress={() =>
|
||||
mutate({
|
||||
item,
|
||||
onToggle,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -50,13 +50,13 @@ export function HorizontalSlider({ value, max, width, props }: SliderProps): Rea
|
||||
orientation='horizontal'
|
||||
{...props}
|
||||
>
|
||||
<JellifySliderTrack size='$2'>
|
||||
<JellifySliderTrack size='$3'>
|
||||
<JellifyActiveSliderTrack />
|
||||
</JellifySliderTrack>
|
||||
<JellifySliderThumb
|
||||
circular
|
||||
index={0}
|
||||
size={'$0.75'} // Anything larger than 14 causes the thumb to be clipped
|
||||
size={'$1.5'}
|
||||
// Increase hit slop for better touch handling
|
||||
hitSlop={{
|
||||
top: 25,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Spacer, XStack, getToken } from 'tamagui'
|
||||
import PlayPauseButton from './buttons'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { XStack, View, useTheme } from 'tamagui'
|
||||
import ExpressivePlayButton from './expressive-play-button'
|
||||
import { RepeatMode } from 'react-native-track-player'
|
||||
import {
|
||||
usePrevious,
|
||||
@@ -10,56 +9,193 @@ import {
|
||||
useToggleShuffle,
|
||||
} from '../../../providers/Player/hooks/mutations'
|
||||
import { useRepeatModeStoreValue, useShuffle } from '../../../stores/player/queue'
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'
|
||||
import { Pressable, StyleSheet } from 'react-native'
|
||||
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
|
||||
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
|
||||
|
||||
/**
|
||||
* M3 Expressive Controls
|
||||
*
|
||||
* Size hierarchy based on interaction frequency:
|
||||
* - Play/Pause: 80px organic blob (highest emphasis)
|
||||
* - Skip/Previous: 48px filled circles (medium emphasis)
|
||||
* - Shuffle/Repeat: 32px pill buttons (low emphasis)
|
||||
*/
|
||||
|
||||
// Skip button - medium emphasis (48px filled circle)
|
||||
function SkipButton({
|
||||
direction,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
direction: 'next' | 'previous'
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const trigger = useHapticFeedback()
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}))
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.9, { damping: 15, stiffness: 400 })
|
||||
trigger('impactLight')
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.skipButton,
|
||||
{ backgroundColor: theme.primaryContainer?.val ?? theme.primary.val + '20' },
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
name={direction === 'next' ? 'skip-next' : 'skip-previous'}
|
||||
size={28}
|
||||
color={theme.primary.val}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle button - low emphasis (32px pill)
|
||||
function TogglePillButton({
|
||||
icon,
|
||||
isActive,
|
||||
onPress,
|
||||
}: {
|
||||
icon: 'shuffle' | 'repeat' | 'repeat-once'
|
||||
isActive: boolean
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const trigger = useHapticFeedback()
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}))
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.88, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
const handlePress = () => {
|
||||
trigger(isActive ? 'impactLight' : 'notificationSuccess')
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={handlePress}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pillButton,
|
||||
{
|
||||
backgroundColor: isActive ? theme.primary.val : 'transparent',
|
||||
borderColor: isActive ? theme.primary.val : theme.color.val + '40',
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
name={icon}
|
||||
size={18}
|
||||
color={isActive ? theme.background.val : theme.color.val}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Controls(): React.JSX.Element {
|
||||
const previous = usePrevious()
|
||||
const skip = useSkip()
|
||||
const repeatMode = useRepeatModeStoreValue()
|
||||
|
||||
const toggleRepeatMode = useToggleRepeatMode()
|
||||
|
||||
const shuffled = useShuffle()
|
||||
|
||||
const { mutate: toggleShuffle } = useToggleShuffle()
|
||||
|
||||
const repeatIcon = repeatMode === RepeatMode.Track ? 'repeat-once' : 'repeat'
|
||||
|
||||
return (
|
||||
<XStack alignItems='center' justifyContent='space-between'>
|
||||
<Icon
|
||||
small
|
||||
color={shuffled ? '$primary' : '$color'}
|
||||
name='shuffle'
|
||||
onPress={() => toggleShuffle(shuffled)}
|
||||
/>
|
||||
<XStack alignItems='center' justifyContent='center' gap='$4' paddingVertical='$2'>
|
||||
{/* Low emphasis: Shuffle (leftmost) */}
|
||||
<View style={styles.pillContainer}>
|
||||
<TogglePillButton
|
||||
icon='shuffle'
|
||||
isActive={shuffled}
|
||||
onPress={() => toggleShuffle(shuffled)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Spacer />
|
||||
{/* Medium emphasis: Previous */}
|
||||
<SkipButton direction='previous' onPress={previous} testID='previous-button-test-id' />
|
||||
|
||||
<Icon
|
||||
name='skip-previous'
|
||||
color='$primary'
|
||||
onPress={previous}
|
||||
large
|
||||
testID='previous-button-test-id'
|
||||
/>
|
||||
{/* High emphasis: Play/Pause (hero button) */}
|
||||
<View style={styles.playButtonContainer}>
|
||||
<ExpressivePlayButton />
|
||||
</View>
|
||||
|
||||
{/* I really wanted a big clunky play button */}
|
||||
<PlayPauseButton size={getToken('$13') - getToken('$9')} />
|
||||
|
||||
<Icon
|
||||
name='skip-next'
|
||||
color='$primary'
|
||||
{/* Medium emphasis: Next */}
|
||||
<SkipButton
|
||||
direction='next'
|
||||
onPress={() => skip(undefined)}
|
||||
large
|
||||
testID='skip-button-test-id'
|
||||
/>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<Icon
|
||||
small
|
||||
color={repeatMode === RepeatMode.Off ? '$color' : '$primary'}
|
||||
name={repeatMode === RepeatMode.Track ? 'repeat-once' : 'repeat'}
|
||||
onPress={async () => toggleRepeatMode()}
|
||||
/>
|
||||
{/* Low emphasis: Repeat (rightmost) */}
|
||||
<View style={styles.pillContainer}>
|
||||
<TogglePillButton
|
||||
icon={repeatIcon}
|
||||
isActive={repeatMode !== RepeatMode.Off}
|
||||
onPress={() => toggleRepeatMode()}
|
||||
/>
|
||||
</View>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pillContainer: {
|
||||
width: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pillButton: {
|
||||
width: 40,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
skipButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playButtonContainer: {
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
})
|
||||
|
||||
188
src/components/Player/components/expressive-play-button.tsx
Normal file
188
src/components/Player/components/expressive-play-button.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react'
|
||||
import { State } from 'react-native-track-player'
|
||||
import { Spinner, View, useTheme } from 'tamagui'
|
||||
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
|
||||
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from 'react-native-reanimated'
|
||||
import { Pressable, StyleSheet } from 'react-native'
|
||||
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
|
||||
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
|
||||
|
||||
/**
|
||||
* M3 Expressive Play Button
|
||||
*
|
||||
* Features:
|
||||
* - Dramatically larger (80px) than other controls
|
||||
* - Organic "petal" shape with asymmetric border radii
|
||||
* - Shape morphing animation on state change
|
||||
* - Filled background with primary color
|
||||
* - Scale animation on press
|
||||
*/
|
||||
|
||||
const BUTTON_SIZE = 84
|
||||
const ICON_SIZE = 40
|
||||
|
||||
// Dramatic organic blob shape - very asymmetric for expressive feel
|
||||
// Think: rounded square with one corner much rounder than others
|
||||
const PETAL_SHAPE_PLAYING = {
|
||||
borderTopLeftRadius: 50, // Very round
|
||||
borderTopRightRadius: 28, // Sharper
|
||||
borderBottomLeftRadius: 28, // Sharper
|
||||
borderBottomRightRadius: 50, // Very round
|
||||
}
|
||||
|
||||
// Inverted asymmetry when paused - creates noticeable morph
|
||||
const PETAL_SHAPE_PAUSED = {
|
||||
borderTopLeftRadius: 28, // Sharper
|
||||
borderTopRightRadius: 50, // Very round
|
||||
borderBottomLeftRadius: 50, // Very round
|
||||
borderBottomRightRadius: 28, // Sharper
|
||||
}
|
||||
|
||||
export default function ExpressivePlayButton(): React.JSX.Element {
|
||||
const togglePlayback = useTogglePlayback()
|
||||
const state = usePlaybackState()
|
||||
const theme = useTheme()
|
||||
const trigger = useHapticFeedback()
|
||||
|
||||
const isPlaying = state === State.Playing
|
||||
const isLoading = state === State.Buffering || state === State.Loading
|
||||
|
||||
// Animation values
|
||||
const scale = useSharedValue(1)
|
||||
const morphProgress = useSharedValue(isPlaying ? 1 : 0)
|
||||
|
||||
// Update morph progress when state changes
|
||||
React.useEffect(() => {
|
||||
morphProgress.value = withSpring(isPlaying ? 1 : 0, {
|
||||
damping: 15,
|
||||
stiffness: 150,
|
||||
})
|
||||
}, [isPlaying, morphProgress])
|
||||
|
||||
const animatedContainerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: scale.value }],
|
||||
borderTopLeftRadius: interpolate(
|
||||
morphProgress.value,
|
||||
[0, 1],
|
||||
[PETAL_SHAPE_PAUSED.borderTopLeftRadius, PETAL_SHAPE_PLAYING.borderTopLeftRadius],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
borderTopRightRadius: interpolate(
|
||||
morphProgress.value,
|
||||
[0, 1],
|
||||
[PETAL_SHAPE_PAUSED.borderTopRightRadius, PETAL_SHAPE_PLAYING.borderTopRightRadius],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
borderBottomLeftRadius: interpolate(
|
||||
morphProgress.value,
|
||||
[0, 1],
|
||||
[
|
||||
PETAL_SHAPE_PAUSED.borderBottomLeftRadius,
|
||||
PETAL_SHAPE_PLAYING.borderBottomLeftRadius,
|
||||
],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
borderBottomRightRadius: interpolate(
|
||||
morphProgress.value,
|
||||
[0, 1],
|
||||
[
|
||||
PETAL_SHAPE_PAUSED.borderBottomRightRadius,
|
||||
PETAL_SHAPE_PLAYING.borderBottomRightRadius,
|
||||
],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.92, { damping: 15, stiffness: 400 })
|
||||
trigger('impactMedium')
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 400 })
|
||||
}
|
||||
|
||||
const handlePress = async () => {
|
||||
trigger('impactLight')
|
||||
await togglePlayback()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.primary.val },
|
||||
animatedContainerStyle,
|
||||
]}
|
||||
>
|
||||
<Spinner size='large' color={theme.background.val} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={isPlaying ? 'pause-button-test-id' : 'play-button-test-id'}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.primary.val },
|
||||
animatedContainerStyle,
|
||||
]}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
size={ICON_SIZE}
|
||||
color={theme.background.val}
|
||||
style={isPlaying ? undefined : styles.playIconOffset}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
button: {
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// Shadow for depth
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
playIconOffset: {
|
||||
// Play icon is visually off-center, nudge it right
|
||||
marginLeft: 4,
|
||||
},
|
||||
})
|
||||
@@ -1,7 +1,4 @@
|
||||
import { Spacer, useTheme, XStack } from 'tamagui'
|
||||
|
||||
import Icon from '../../Global/components/icon'
|
||||
|
||||
import { useTheme, XStack, View } from 'tamagui'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { PlayerParamList } from '../../../screens/Player/types'
|
||||
@@ -9,8 +6,18 @@ import { CastButton, MediaHlsSegmentFormat, useRemoteMediaClient } from 'react-n
|
||||
import { useEffect } from 'react'
|
||||
import usePlayerEngineStore from '../../../stores/player/engine'
|
||||
import useRawLyrics from '../../../api/queries/lyrics'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import { useCurrentTrack } from '../../../stores/player/queue'
|
||||
import { Pressable, StyleSheet } from 'react-native'
|
||||
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
|
||||
|
||||
export default function Footer(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
|
||||
@@ -23,6 +30,23 @@ export default function Footer(): React.JSX.Element {
|
||||
|
||||
const { data: lyrics } = useRawLyrics()
|
||||
|
||||
// Pulse animation for lyrics button when available
|
||||
const pulseOpacity = useSharedValue(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (lyrics) {
|
||||
pulseOpacity.value = withRepeat(
|
||||
withSequence(withTiming(0.6, { duration: 800 }), withTiming(1, { duration: 800 })),
|
||||
3,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}, [lyrics, pulseOpacity])
|
||||
|
||||
const lyricsAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: pulseOpacity.value,
|
||||
}))
|
||||
|
||||
function sanitizeJellyfinUrl(url: string): { url: string; extension: string | null } {
|
||||
// Priority order for extensions
|
||||
const priority = ['mp4', 'mp3', 'mov', 'm4a', '3gp']
|
||||
@@ -90,33 +114,63 @@ export default function Footer(): React.JSX.Element {
|
||||
}, [remoteMediaClient, nowPlaying, playerEngineData])
|
||||
|
||||
return (
|
||||
<XStack justifyContent='center' alignItems='center' gap={'$3'}>
|
||||
<XStack alignItems='center' justifyContent='flex-start'>
|
||||
<CastButton style={{ tintColor: theme.color.val, width: 28, height: 28 }} />
|
||||
<XStack justifyContent='space-between' alignItems='center' paddingTop='$2'>
|
||||
{/* Left section: Cast + Lyrics with styled containers */}
|
||||
<XStack alignItems='center' gap='$2' flex={1}>
|
||||
{/* Cast button in subtle circular container */}
|
||||
<View style={[styles.iconCircle, { borderColor: theme.color.val + '40' }]}>
|
||||
<CastButton style={{ tintColor: theme.color.val, width: 20, height: 20 }} />
|
||||
</View>
|
||||
|
||||
{lyrics && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} style={lyricsAnimatedStyle}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('LyricsScreen', { lyrics: lyrics })}
|
||||
style={[styles.iconCircle, { borderColor: theme.primary.val }]}
|
||||
>
|
||||
<MaterialDesignIcons
|
||||
name='message-text-outline'
|
||||
size={20}
|
||||
color={theme.primary.val}
|
||||
/>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
{lyrics && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Icon
|
||||
small
|
||||
name='message-text-outline'
|
||||
onPress={() => navigation.navigate('LyricsScreen', { lyrics: lyrics })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Spacer flex={1} />
|
||||
|
||||
<XStack alignItems='center' justifyContent='flex-end' flex={1}>
|
||||
<Icon
|
||||
small
|
||||
{/* Right section: Queue button with pill emphasis */}
|
||||
<View style={styles.queueContainer}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('QueueScreen')}
|
||||
testID='queue-button-test-id'
|
||||
name='playlist-music'
|
||||
onPress={() => {
|
||||
navigation.navigate('QueueScreen')
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
style={[styles.queueButton, { borderColor: theme.color.val + '50' }]}
|
||||
>
|
||||
<MaterialDesignIcons name='playlist-music' size={20} color={theme.color.val} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iconCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
queueContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
queueButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1.5,
|
||||
gap: 6,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,6 +91,8 @@ function PlayerArtwork(): React.JSX.Element {
|
||||
key={`${nowPlaying!.item.AlbumId}-item-image`}
|
||||
style={{
|
||||
...animatedStyle,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ItemImage
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
|
||||
<XStack>
|
||||
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
|
||||
<TextTicker {...TextTickerConfig} style={{ height: getToken('$9') }}>
|
||||
<Text bold fontSize={'$6'}>
|
||||
<Text bold fontSize={'$7'}>
|
||||
{trackTitle}
|
||||
</Text>
|
||||
</TextTicker>
|
||||
@@ -119,9 +119,21 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
|
||||
</TextTicker>
|
||||
</YStack>
|
||||
|
||||
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}>
|
||||
<Icon
|
||||
name='dots-horizontal-circle-outline'
|
||||
{/* M3 Expressive: styled action buttons with circular containers */}
|
||||
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$2'}>
|
||||
{/* Context menu - subtle circle container */}
|
||||
<XStack
|
||||
width={44}
|
||||
height={44}
|
||||
borderRadius={22}
|
||||
backgroundColor={'$colorTransparent'}
|
||||
borderWidth={1.5}
|
||||
borderColor={'$color'}
|
||||
opacity={0.6}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
pressStyle={{ opacity: 0.9, scale: 0.95 }}
|
||||
animation='quick'
|
||||
onPress={() =>
|
||||
navigationRef.navigate('Context', {
|
||||
item: nowPlaying!.item,
|
||||
@@ -135,8 +147,11 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Icon name='dots-horizontal' small />
|
||||
</XStack>
|
||||
|
||||
{/* Favorites - larger circle container with primary accent when favorited */}
|
||||
<FavoriteButton item={nowPlaying!.item} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
Reference in New Issue
Block a user