Enhance player controls with expressive animations and improved UI elements

This commit is contained in:
skalthoff
2025-12-10 12:43:43 -08:00
parent 8d0dad7a7b
commit a3718e52c1
7 changed files with 550 additions and 120 deletions

View File

@@ -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',
},
})

View File

@@ -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,

View File

@@ -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,
},
})

View 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,
},
})

View File

@@ -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,
},
})

View File

@@ -91,6 +91,8 @@ function PlayerArtwork(): React.JSX.Element {
key={`${nowPlaying!.item.AlbumId}-item-image`}
style={{
...animatedStyle,
borderRadius: 16,
overflow: 'hidden',
}}
>
<ItemImage

View File

@@ -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>