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 */}