diff --git a/src/components/Global/components/favorite-button.tsx b/src/components/Global/components/favorite-button.tsx index 7ab838c9..aedd3690 100644 --- a/src/components/Global/components/favorite-button.tsx +++ b/src/components/Global/components/favorite-button.tsx @@ -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 ? ( - - ) : isFavorite ? ( - - ) : ( - - ) -} + 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 ? ( - - ) : ( - - - 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 ( + + + + ) + } + + return ( + + + + + + ) } -function RemoveFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element { - const { mutate, isPending } = useAddFavorite() - - return isPending ? ( - - ) : ( - - - mutate({ - item, - onToggle, - }) - } - /> - - ) -} +const styles = StyleSheet.create({ + container: { + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 1.5, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/components/Global/helpers/slider.tsx b/src/components/Global/helpers/slider.tsx index d55a6d06..a8cbef4e 100644 --- a/src/components/Global/helpers/slider.tsx +++ b/src/components/Global/helpers/slider.tsx @@ -50,13 +50,13 @@ export function HorizontalSlider({ value, max, width, props }: SliderProps): Rea orientation='horizontal' {...props} > - + 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 ( + + + + + + ) +} + +// 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 ( + + + + + + ) +} 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 ( - - toggleShuffle(shuffled)} - /> + + {/* Low emphasis: Shuffle (leftmost) */} + + toggleShuffle(shuffled)} + /> + - + {/* Medium emphasis: Previous */} + - + {/* High emphasis: Play/Pause (hero button) */} + + + - {/* I really wanted a big clunky play button */} - - - skip(undefined)} - large testID='skip-button-test-id' /> - - - toggleRepeatMode()} - /> + {/* Low emphasis: Repeat (rightmost) */} + + toggleRepeatMode()} + /> + ) } + +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, + }, +}) diff --git a/src/components/Player/components/expressive-play-button.tsx b/src/components/Player/components/expressive-play-button.tsx new file mode 100644 index 00000000..0f2bc5c7 --- /dev/null +++ b/src/components/Player/components/expressive-play-button.tsx @@ -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 ( + + + + + + ) + } + + return ( + + + + + + + + ) +} + +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, + }, +}) diff --git a/src/components/Player/components/footer.tsx b/src/components/Player/components/footer.tsx index 753915ae..5bc72f26 100644 --- a/src/components/Player/components/footer.tsx +++ b/src/components/Player/components/footer.tsx @@ -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>() @@ -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 ( - - - + + {/* Left section: Cast + Lyrics with styled containers */} + + {/* Cast button in subtle circular container */} + + + + + {lyrics && ( + + navigation.navigate('LyricsScreen', { lyrics: lyrics })} + style={[styles.iconCircle, { borderColor: theme.primary.val }]} + > + + + + )} - {lyrics && ( - - navigation.navigate('LyricsScreen', { lyrics: lyrics })} - /> - - )} - - - - - + navigation.navigate('QueueScreen')} testID='queue-button-test-id' - name='playlist-music' - onPress={() => { - navigation.navigate('QueueScreen') - }} - /> - + style={[styles.queueButton, { borderColor: theme.color.val + '50' }]} + > + + + ) } + +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, + }, +}) diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx index e066ac0a..0623b068 100644 --- a/src/components/Player/components/header.tsx +++ b/src/components/Player/components/header.tsx @@ -91,6 +91,8 @@ function PlayerArtwork(): React.JSX.Element { key={`${nowPlaying!.item.AlbumId}-item-image`} style={{ ...animatedStyle, + borderRadius: 16, + overflow: 'hidden', }} > - + {trackTitle} @@ -119,9 +119,21 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem - - + {/* Context menu - subtle circle container */} + navigationRef.navigate('Context', { item: nowPlaying!.item, @@ -135,8 +147,11 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem : undefined, }) } - /> + > + + + {/* Favorites - larger circle container with primary accent when favorited */}