optimize further

This commit is contained in:
Boof2015
2026-07-12 12:16:52 -04:00
parent 5cc1099853
commit 804e47a7ca
69 changed files with 588 additions and 292 deletions
+6 -4
View File
@@ -38,6 +38,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { isWideWindow } from '@/theme/adaptive'; import { isWideWindow } from '@/theme/adaptive';
import { useEQStore } from '@/stores/eqStore'; import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore'; import { useScopeActive } from '@/scope/scopeStore';
@@ -75,6 +76,7 @@ const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass'
export default function EQScreen() { export default function EQScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const eq = useEQStore(); const eq = useEQStore();
@@ -217,7 +219,7 @@ export default function EQScreen() {
}; };
const presetRowEl = ( const presetRowEl = (
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.presetRow, isWide && styles.sideItem]} style={[styles.presetRow, isWide && styles.sideItem]}
onPress={() => setSheet('preset')} onPress={() => setSheet('preset')}
> >
@@ -285,7 +287,7 @@ export default function EQScreen() {
onChange={eq.setPreamp} onChange={eq.setPreamp}
/> />
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]} style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
onPress={eq.toggleEnabled} onPress={eq.toggleEnabled}
> >
@@ -321,10 +323,10 @@ export default function EQScreen() {
</Text> </Text>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
<Ionicons name="save-outline" size={20} color={colors.textSecondary} /> <Ionicons name="save-outline" size={20} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} /> <Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
+21 -11
View File
@@ -28,10 +28,12 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { rgbaFromHex } from '@/theme/colorUtils'; import { rgbaFromHex } from '@/theme/colorUtils';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { useSearchStore } from '@/stores/searchStore'; import { useSearchStore } from '@/stores/searchStore';
import { import {
playTracks, playTracks,
@@ -93,6 +95,7 @@ function SectionHeader({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}> <View style={styles.sectionTitleGroup}>
@@ -106,7 +109,7 @@ function SectionHeader({
) : null} ) : null}
</View> </View>
{onActionPress && actionLabel ? ( {onActionPress && actionLabel ? (
<Pressable style={styles.seeAllButton} onPress={onActionPress} accessibilityRole="button"> <Pressable style={styles.seeAllButton} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onActionPress} accessibilityRole="button">
<Text variant="label" color={colors.accentText}> <Text variant="label" color={colors.accentText}>
{actionLabel} {actionLabel}
</Text> </Text>
@@ -160,6 +163,7 @@ function NowPlayingCard({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const isPlaying = playbackState === 'playing'; const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading'; const isLoading = playbackState === 'loading';
const scopeActive = useScopeActive(); const scopeActive = useScopeActive();
@@ -194,7 +198,7 @@ function NowPlayingCard({
{scopeActive && cardSize.width > 0 ? ( {scopeActive && cardSize.width > 0 ? (
<View pointerEvents="none" style={styles.playerSpectrumVeil} /> <View pointerEvents="none" style={styles.playerSpectrumVeil} />
) : null} ) : null}
<Pressable style={styles.playerArt} onPress={onOpen} accessibilityRole="button"> <Pressable style={styles.playerArt} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onOpen} accessibilityRole="button">
{track.artworkData ? ( {track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.image} contentFit="cover" /> <Image source={{ uri: track.artworkData }} style={styles.image} contentFit="cover" />
) : ( ) : (
@@ -213,20 +217,20 @@ function NowPlayingCard({
</Text> </Text>
<NowPlayingSeekStrip /> <NowPlayingSeekStrip />
<View style={styles.placeholderControls}> <View style={styles.placeholderControls}>
<Pressable hitSlop={10} onPress={() => void skipToPrevious()}> <Pressable hitSlop={10} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={() => void skipToPrevious()}>
<Ionicons name="play-skip-back" size={22} color={colors.textSecondary} /> <Ionicons name="play-skip-back" size={22} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable hitSlop={10} onPress={() => void togglePlay()} style={styles.playCircle}> <Pressable hitSlop={10} onPress={() => void togglePlay()} style={styles.playCircle} android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}>
<Ionicons <Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'} name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={18} size={18}
color={colors.bgPrimary} color={colors.bgPrimary}
/> />
</Pressable> </Pressable>
<Pressable hitSlop={10} onPress={() => void skipToNext()}> <Pressable hitSlop={10} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={() => void skipToNext()}>
<Ionicons name="play-skip-forward" size={22} color={colors.textSecondary} /> <Ionicons name="play-skip-forward" size={22} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable hitSlop={10} onPress={onOpen}> <Pressable hitSlop={10} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onOpen}>
<Ionicons name="expand-outline" size={21} color={colors.textTertiary} /> <Ionicons name="expand-outline" size={21} color={colors.textTertiary} />
</Pressable> </Pressable>
</View> </View>
@@ -243,8 +247,9 @@ function RecentlyAddedAlbum({
onPress: () => void; onPress: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
return ( return (
<Pressable style={styles.recentAlbum} onPress={onPress} accessibilityRole="button"> <Pressable style={styles.recentAlbum} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPress} accessibilityRole="button">
<AlbumCover album={album} size={112} /> <AlbumCover album={album} size={112} />
<Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}> <Text variant="body" numberOfLines={1} style={styles.recentAlbumTitle}>
{album.album} {album.album}
@@ -273,11 +278,12 @@ function RandomAlbumCard({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const disabled = tracks.length === 0; const disabled = tracks.length === 0;
return ( return (
<View style={styles.randomCard}> <View style={styles.randomCard}>
<Pressable style={styles.randomMain} onPress={onOpen} accessibilityRole="button"> <Pressable style={styles.randomMain} android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onOpen} accessibilityRole="button">
<AlbumCover album={album} size={96} /> <AlbumCover album={album} size={96} />
<View style={styles.randomMeta}> <View style={styles.randomMeta}>
<Text variant="label" color={colors.textTertiary}> <Text variant="label" color={colors.textTertiary}>
@@ -294,7 +300,7 @@ function RandomAlbumCard({
</Text> </Text>
</View> </View>
<Pressable <Pressable
style={styles.reroll} style={styles.reroll} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onReroll} onPress={onReroll}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
@@ -306,6 +312,7 @@ function RandomAlbumCard({
<View style={styles.randomActions}> <View style={styles.randomActions}>
<Pressable <Pressable
android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.primaryButton, disabled && styles.buttonDisabled]} style={[styles.primaryButton, disabled && styles.buttonDisabled]}
disabled={disabled} disabled={disabled}
onPress={onPlay} onPress={onPlay}
@@ -317,6 +324,7 @@ function RandomAlbumCard({
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.secondaryButton, disabled && styles.buttonDisabled]} style={[styles.secondaryButton, disabled && styles.buttonDisabled]}
disabled={disabled} disabled={disabled}
onPress={onShuffle} onPress={onShuffle}
@@ -341,6 +349,7 @@ function EmptyHomeCard({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<View style={styles.emptyCard}> <View style={styles.emptyCard}>
<Ionicons name="folder-open-outline" size={34} color={colors.textTertiary} /> <Ionicons name="folder-open-outline" size={34} color={colors.textTertiary} />
@@ -356,6 +365,7 @@ function EmptyHomeCard({
) : null} ) : null}
</View> </View>
<Pressable <Pressable
android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.primaryButton} style={styles.primaryButton}
onPress={onManageFolders} onPress={onManageFolders}
accessibilityRole="button" accessibilityRole="button"
@@ -486,7 +496,7 @@ export default function HomeScreen() {
<NowPlayingCard <NowPlayingCard
track={currentTrack} track={currentTrack}
playbackState={playbackState} playbackState={playbackState}
onOpen={() => router.push('/now-playing')} onOpen={() => usePlayerUiStore.getState().openPlayer()}
/> />
</View> </View>
) : null} ) : null}
@@ -502,7 +512,7 @@ export default function HomeScreen() {
<NowPlayingCard <NowPlayingCard
track={currentTrack} track={currentTrack}
playbackState={playbackState} playbackState={playbackState}
onOpen={() => router.push('/now-playing')} onOpen={() => usePlayerUiStore.getState().openPlayer()}
/> />
) : randomAlbum ? ( ) : randomAlbum ? (
<RandomAlbumCard <RandomAlbumCard
+5 -2
View File
@@ -26,6 +26,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import type { Palette } from '@/theme/palettes'; import type { Palette } from '@/theme/palettes';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -291,6 +292,7 @@ function SectionHeader({
onPress?: () => void; onPress?: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
@@ -303,7 +305,7 @@ function SectionHeader({
</Text> </Text>
</View> </View>
{onPress ? ( {onPress ? (
<Pressable style={styles.seeAllButton} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.seeAllButton} onPress={onPress} accessibilityRole="button">
<Text variant="label" color={colors.accentText}> <Text variant="label" color={colors.accentText}>
See all See all
</Text> </Text>
@@ -322,6 +324,7 @@ function AlbumRail({
onAlbumPress: (album: ArtistAlbum) => void; onAlbumPress: (album: ArtistAlbum) => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
return ( return (
<ScrollView <ScrollView
horizontal horizontal
@@ -329,7 +332,7 @@ function AlbumRail({
contentContainerStyle={styles.albumRail} contentContainerStyle={styles.albumRail}
> >
{albums.map((album) => ( {albums.map((album) => (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={album.identity_key} key={album.identity_key}
style={styles.albumCard} style={styles.albumCard}
onPress={() => onAlbumPress(album)} onPress={() => onAlbumPress(album)}
@@ -12,12 +12,14 @@ import { Text } from '@/components/Text';
import { AlbumGridItem } from '@/components/library/AlbumGridItem'; import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import { buildArtistDetail } from '@/library/artistDetail'; import { buildArtistDetail } from '@/library/artistDetail';
export default function ArtistAlbumsScreen() { export default function ArtistAlbumsScreen() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks); const allTracks = useLibraryStore((s) => s.tracks);
@@ -30,7 +32,7 @@ export default function ArtistAlbumsScreen() {
return ( return (
<Screen> <Screen>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary} numberOfLines={1}> <Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{name} {name}
@@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
@@ -23,6 +24,7 @@ import type { DbTrack } from '@/types/library';
export default function ArtistAppearancesScreen() { export default function ArtistAppearancesScreen() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks); const allTracks = useLibraryStore((s) => s.tracks);
@@ -43,7 +45,7 @@ export default function ArtistAppearancesScreen() {
return ( return (
<Screen> <Screen>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary} numberOfLines={1}> <Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{name} {name}
@@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
@@ -23,6 +24,7 @@ import type { DbTrack } from '@/types/library';
export default function ArtistSongsScreen() { export default function ArtistSongsScreen() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks); const allTracks = useLibraryStore((s) => s.tracks);
@@ -43,7 +45,7 @@ export default function ArtistSongsScreen() {
return ( return (
<Screen> <Screen>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary} numberOfLines={1}> <Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{name} {name}
+5 -3
View File
@@ -39,6 +39,7 @@ import {
} from '@/components/search/PullSearchGesture'; } from '@/components/search/PullSearchGesture';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore'; import { useSearchStore } from '@/stores/searchStore';
@@ -77,6 +78,7 @@ const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
export default function LibraryScreen() { export default function LibraryScreen() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const viewMode = useLibraryStore((s) => s.viewMode); const viewMode = useLibraryStore((s) => s.viewMode);
const setViewMode = useLibraryStore((s) => s.setViewMode); const setViewMode = useLibraryStore((s) => s.setViewMode);
@@ -244,7 +246,7 @@ export default function LibraryScreen() {
Library Library
</Text> </Text>
{!isEmpty ? ( {!isEmpty ? (
<Pressable <Pressable android_ripple={ripple.bounded}
hitSlop={8} hitSlop={8}
onPress={() => openQuickSearch()} onPress={() => openQuickSearch()}
accessibilityRole="button" accessibilityRole="button"
@@ -281,14 +283,14 @@ export default function LibraryScreen() {
<Text variant="label"> <Text variant="label">
{selectedIds.size} selected {selectedIds.size} selected
</Text> </Text>
<Pressable onPress={exitSelection} hitSlop={8} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} onPress={exitSelection} hitSlop={8} accessibilityRole="button">
<Text variant="label" color={colors.accentText}> <Text variant="label" color={colors.accentText}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
) : sortable ? ( ) : sortable ? (
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.sortTrigger} style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)} onPress={() => setSortSheetOpen(true)}
accessibilityRole="button" accessibilityRole="button"
+5 -2
View File
@@ -27,6 +27,7 @@ import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController'; import { playTracks, shuffleTracks } from '@/audio/playbackController';
@@ -54,9 +55,10 @@ function basename(path: string): string {
function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) { function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<Pressable style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<View style={styles.missingMeta}> <View style={styles.missingMeta}>
<Text variant="body" numberOfLines={1} color={colors.textTertiary}> <Text variant="body" numberOfLines={1} color={colors.textTertiary}>
{entry.fallback_title ?? basename(entry.track_path)} {entry.fallback_title ?? basename(entry.track_path)}
@@ -74,6 +76,7 @@ type Prompt = { kind: 'rename'; playlist: Playlist } | null;
export default function PlaylistScreen() { export default function PlaylistScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@@ -283,7 +286,7 @@ export default function PlaylistScreen() {
heroMeta={<Text variant="label">{meta}</Text>} heroMeta={<Text variant="label">{meta}</Text>}
heroExtra={ heroExtra={
isDynamic && playlistId != null ? ( isDynamic && playlistId != null ? (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.editRules} style={styles.editRules}
onPress={() => onPress={() =>
router.push({ router.push({
@@ -27,6 +27,7 @@ import {
} from '@/components/sheets/AppSheet'; } from '@/components/sheets/AppSheet';
import { fonts, fontSize, radius, spacing } from '@/theme'; import { fonts, fontSize, radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { import {
DYNAMIC_PLAYLIST_PRESETS, DYNAMIC_PLAYLIST_PRESETS,
@@ -327,15 +328,16 @@ function DraftActions({
onApply: () => void; onApply: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<View style={styles.sheetActions}> <View style={styles.sheetActions}>
<Pressable style={[styles.sheetButton, styles.cancelButton]} onPress={onCancel} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={[styles.sheetButton, styles.cancelButton]} onPress={onCancel} accessibilityRole="button">
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.sheetButton, styles.applyButton, disabled && styles.applyDisabled]} style={[styles.sheetButton, styles.applyButton, disabled && styles.applyDisabled]}
disabled={disabled} disabled={disabled}
onPress={onApply} onPress={onApply}
@@ -529,6 +531,7 @@ function ConditionEditorSheet({
onApply: () => void; onApply: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const draft = target.draft; const draft = target.draft;
const error = validateCondition(draft); const error = validateCondition(draft);
@@ -536,7 +539,7 @@ function ConditionEditorSheet({
return ( return (
<AppSheet onClose={onCancel}> <AppSheet onClose={onCancel}>
<AppSheetTitle title={target.mode === 'new' ? 'Add filter' : 'Edit filter'} /> <AppSheetTitle title={target.mode === 'new' ? 'Add filter' : 'Edit filter'} />
<Pressable style={styles.sheetSelectRow} onPress={onChangeField} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.sheetSelectRow} onPress={onChangeField} accessibilityRole="button">
<View style={styles.sheetSelectText}> <View style={styles.sheetSelectText}>
<Text variant="caption" color={colors.textTertiary}> <Text variant="caption" color={colors.textTertiary}>
FIELD FIELD
@@ -568,7 +571,7 @@ function ConditionEditorSheet({
<View style={styles.conditionSheetFooter}> <View style={styles.conditionSheetFooter}>
{target.mode === 'edit' ? ( {target.mode === 'edit' ? (
<Pressable style={styles.removeButton} onPress={onRemove} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.removeButton} onPress={onRemove} accessibilityRole="button">
<Ionicons name="trash-outline" size={17} color={colors.warning} /> <Ionicons name="trash-outline" size={17} color={colors.warning} />
<Text variant="label" color={colors.warning}> <Text variant="label" color={colors.warning}>
Remove Remove
@@ -713,11 +716,12 @@ function FilterCard({
onRemove: () => void; onRemove: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const option = fieldOptionForKey(getConditionFieldKey(condition)); const option = fieldOptionForKey(getConditionFieldKey(condition));
return ( return (
<Pressable style={styles.ruleCard} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.ruleCard} onPress={onPress} accessibilityRole="button">
<View style={styles.cardIcon}> <View style={styles.cardIcon}>
<Ionicons name={option?.icon ?? 'options-outline'} size={18} color={colors.accent} /> <Ionicons name={option?.icon ?? 'options-outline'} size={18} color={colors.accent} />
</View> </View>
@@ -729,7 +733,7 @@ function FilterCard({
{fieldGroupLabel(condition)} {fieldGroupLabel(condition)}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.cardRemove} style={styles.cardRemove}
onPress={onRemove} onPress={onRemove}
hitSlop={8} hitSlop={8}
@@ -744,6 +748,7 @@ function FilterCard({
export default function DynamicPlaylistEditorScreen() { export default function DynamicPlaylistEditorScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>(); const { id } = useLocalSearchParams<{ id?: string }>();
@@ -1036,7 +1041,7 @@ export default function DynamicPlaylistEditorScreen() {
> >
<Screen padded={false}> <Screen padded={false}>
<View style={styles.header}> <View style={styles.header}>
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={() => router.back()} onPress={() => router.back()}
hitSlop={8} hitSlop={8}
style={styles.headerButton} style={styles.headerButton}
@@ -1080,7 +1085,7 @@ export default function DynamicPlaylistEditorScreen() {
contentContainerStyle={styles.presetRow} contentContainerStyle={styles.presetRow}
> >
{DYNAMIC_PLAYLIST_PRESETS.map((preset) => ( {DYNAMIC_PLAYLIST_PRESETS.map((preset) => (
<Pressable <Pressable android_ripple={ripple.bounded}
key={preset.id} key={preset.id}
style={styles.presetChip} style={styles.presetChip}
onPress={() => applyPreset(preset.rules)} onPress={() => applyPreset(preset.rules)}
@@ -1100,7 +1105,7 @@ export default function DynamicPlaylistEditorScreen() {
<Text variant="caption" style={styles.sectionLabel}> <Text variant="caption" style={styles.sectionLabel}>
FILTERS FILTERS
</Text> </Text>
<Pressable style={styles.inlineAction} onPress={() => openFieldPicker('new')} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.inlineAction} onPress={() => openFieldPicker('new')} accessibilityRole="button">
<Ionicons name="add" size={16} color={colors.accent} /> <Ionicons name="add" size={16} color={colors.accent} />
<Text variant="label" color={colors.accent}> <Text variant="label" color={colors.accent}>
Add filter Add filter
@@ -1133,7 +1138,7 @@ export default function DynamicPlaylistEditorScreen() {
<Text variant="caption" style={styles.sectionLabel}> <Text variant="caption" style={styles.sectionLabel}>
ORDER ORDER
</Text> </Text>
<Pressable style={styles.sortCard} onPress={openSortSheet} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.sortCard} onPress={openSortSheet} accessibilityRole="button">
<View style={styles.cardIcon}> <View style={styles.cardIcon}>
<Ionicons name="swap-vertical" size={18} color={colors.accent} /> <Ionicons name="swap-vertical" size={18} color={colors.accent} />
</View> </View>
@@ -1161,7 +1166,7 @@ export default function DynamicPlaylistEditorScreen() {
{status.label} {status.label}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.previewButton, normalizedRulesError !== null && styles.disabled]} style={[styles.previewButton, normalizedRulesError !== null && styles.disabled]}
disabled={normalizedRulesError !== null} disabled={normalizedRulesError !== null}
onPress={() => setSheet({ kind: 'preview' })} onPress={() => setSheet({ kind: 'preview' })}
@@ -1172,7 +1177,7 @@ export default function DynamicPlaylistEditorScreen() {
Preview Preview
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.saveButton, saveDisabled && styles.disabled]} style={[styles.saveButton, saveDisabled && styles.disabled]}
disabled={saveDisabled} disabled={saveDisabled}
onPress={save} onPress={save}
+2 -2
View File
@@ -1,6 +1,6 @@
import { import {
getNotificationClickRedirectPath,
isNotificationClickPath, isNotificationClickPath,
resolveNotificationClick,
} from '@/audio/notificationIntent'; } from '@/audio/notificationIntent';
import { getEQPresetShareRedirectPath } from '@/audio/eqShareIntent'; import { getEQPresetShareRedirectPath } from '@/audio/eqShareIntent';
@@ -10,7 +10,7 @@ type RedirectSystemPathEvent = {
}; };
export async function redirectSystemPath({ path }: RedirectSystemPathEvent): Promise<string> { export async function redirectSystemPath({ path }: RedirectSystemPathEvent): Promise<string> {
if (isNotificationClickPath(path)) return getNotificationClickRedirectPath(); if (isNotificationClickPath(path)) return resolveNotificationClick();
const eqShareRedirect = getEQPresetShareRedirectPath(path); const eqShareRedirect = getEQPresetShareRedirectPath(path);
if (eqShareRedirect) return eqShareRedirect; if (eqShareRedirect) return eqShareRedirect;
+10 -13
View File
@@ -17,6 +17,7 @@ import {
JetBrainsMono_500Medium, JetBrainsMono_500Medium,
} from '@expo-google-fonts/jetbrains-mono'; } from '@expo-google-fonts/jetbrains-mono';
import { usePlaybackSync } from '@/audio/usePlaybackSync'; import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { NowPlayingHost } from '@/components/player/NowPlayingHost';
import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay'; import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay';
import { useScopeLifecycle } from '@/scope/useScopeLifecycle'; import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
@@ -45,10 +46,12 @@ import { useOnboardingStore } from '@/stores/onboardingStore';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow'; import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTheme } from '@/theme/themed'; import { useTheme } from '@/theme/themed';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the // Anchor the root stack at the tabs so a deep link straight to a top-level route
// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]` // (the widget's `recently-played`, the notification-click redirect) builds
// instead of just `[route]`. Without this, dismissing the now-playing modal pops to an empty // `[(tabs), route]` instead of just `[route]` — backing out of a deep-linked
// stack → blank screen. Only affects deep-link/launch ordering; normal nav is unchanged. // route must never land on an empty stack. Only affects deep-link/launch
// ordering; normal nav is unchanged. (Now-playing itself is no longer a route —
// it's the store-gated NowPlayingHost overlay.)
export const unstable_settings = { export const unstable_settings = {
initialRouteName: '(tabs)', initialRouteName: '(tabs)',
}; };
@@ -357,18 +360,12 @@ export default function RootLayout() {
}} }}
> >
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen
name="now-playing"
options={{
presentation: 'transparentModal',
animation: 'none',
gestureEnabled: false,
contentStyle: { backgroundColor: 'transparent' },
}}
/>
</Stack> </Stack>
{onboardingComplete ? ( {onboardingComplete ? (
<> <>
{/* Always-mounted player overlay (store-gated); open/close is a pure
UI-thread slide with zero mount cost after the first mount. */}
<NowPlayingHost />
<QuickSearchOverlay /> <QuickSearchOverlay />
<SyncConflictPrompt /> <SyncConflictPrompt />
</> </>
+22 -12
View File
@@ -27,8 +27,10 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive'; import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote'; import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
// Layout mirrors now-playing's adaptive pattern (minus the scope stage): a // Layout mirrors now-playing's adaptive pattern (minus the scope stage): a
@@ -189,9 +191,10 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
disabled: boolean; disabled: boolean;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.discoveredRow, disabled && styles.buttonDisabled]} style={[styles.discoveredRow, disabled && styles.buttonDisabled]}
onPress={() => onPair(desktop)} onPress={() => onPair(desktop)}
disabled={disabled} disabled={disabled}
@@ -219,6 +222,7 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
export default function DesktopRemoteScreen() { export default function DesktopRemoteScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>(); const { pair } = useLocalSearchParams<{ pair?: string }>();
@@ -390,7 +394,7 @@ export default function DesktopRemoteScreen() {
maxLength={6} maxLength={6}
textContentType="oneTimeCode" textContentType="oneTimeCode"
/> />
<Pressable <Pressable android_ripple={ripple.bounded}
style={[ style={[
styles.primaryButton, styles.primaryButton,
(normalizedPinInput.length !== 6 || !pinPairingActive) && styles.buttonDisabled, (normalizedPinInput.length !== 6 || !pinPairingActive) && styles.buttonDisabled,
@@ -416,7 +420,7 @@ export default function DesktopRemoteScreen() {
Open Astra Desktop settings, enable Phone Remote, then scan or paste the pairing link. Open Astra Desktop settings, enable Phone Remote, then scan or paste the pairing link.
</Text> </Text>
<View style={styles.actionRow}> <View style={styles.actionRow}>
<Pressable style={styles.primaryButton} onPress={() => router.push('/desktop-remote/scan' as never)}> <Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => router.push('/desktop-remote/scan' as never)}>
<Ionicons name="scan" size={18} color={colors.accentTextStrong} /> <Ionicons name="scan" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Scan QR Scan QR
@@ -433,7 +437,7 @@ export default function DesktopRemoteScreen() {
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
/> />
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.secondaryButton, !pairingLink.trim() && styles.buttonDisabled]} style={[styles.secondaryButton, !pairingLink.trim() && styles.buttonDisabled]}
disabled={!pairingLink.trim()} disabled={!pairingLink.trim()}
onPress={() => void pairFromInput(pairingLink)} onPress={() => void pairFromInput(pairingLink)}
@@ -470,7 +474,7 @@ export default function DesktopRemoteScreen() {
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<Pressable <Pressable android_ripple={ripple.bounded}
style={[ style={[
styles.secondaryButton, styles.secondaryButton,
(!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled, (!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled,
@@ -523,7 +527,7 @@ export default function DesktopRemoteScreen() {
> >
<View style={[styles.remoteShell, { width: remoteLayout.contentWidth }]}> <View style={[styles.remoteShell, { width: remoteLayout.contentWidth }]}>
<View style={styles.remoteNowHeader}> <View style={styles.remoteNowHeader}>
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={12}> <Pressable android_ripple={ripple.bounded} style={styles.headerBtn} onPress={() => router.back()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} /> <Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable> </Pressable>
<View style={styles.headerMid}> <View style={styles.headerMid}>
@@ -534,7 +538,7 @@ export default function DesktopRemoteScreen() {
{remoteSource} {remoteSource}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.headerBtn} style={styles.headerBtn}
onPress={() => void reconnect()} onPress={() => void reconnect()}
hitSlop={12} hitSlop={12}
@@ -582,20 +586,26 @@ export default function DesktopRemoteScreen() {
</View> </View>
<View style={styles.manageActions}> <View style={styles.manageActions}>
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.primaryButton} style={styles.primaryButton}
onPress={() => router.replace('/now-playing' as never)} onPress={() => {
// The player is an overlay, not a route: open it and pop this
// screen so it slides in above wherever the user came from.
usePlayerUiStore.getState().openPlayer();
if (router.canGoBack()) router.back();
else router.replace('/');
}}
> >
<Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} /> <Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Open Now Playing Open Now Playing
</Text> </Text>
</Pressable> </Pressable>
<Pressable style={styles.secondaryButton} onPress={() => void reconnect()}> <Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => void reconnect()}>
<Ionicons name="refresh" size={18} color={colors.textPrimary} /> <Ionicons name="refresh" size={18} color={colors.textPrimary} />
<Text variant="body">Reconnect</Text> <Text variant="body">Reconnect</Text>
</Pressable> </Pressable>
<Pressable style={styles.dangerButton} onPress={confirmForget}> <Pressable android_ripple={ripple.bounded} style={styles.dangerButton} onPress={confirmForget}>
<Ionicons name="trash-outline" size={18} color={colors.warning} /> <Ionicons name="trash-outline" size={18} color={colors.warning} />
<Text variant="body" color={colors.warning}> <Text variant="body" color={colors.warning}>
Forget desktop Forget desktop
@@ -630,7 +640,7 @@ export default function DesktopRemoteScreen() {
) : ( ) : (
<> <>
<View style={styles.topBar}> <View style={styles.topBar}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Settings Settings
+4 -2
View File
@@ -19,10 +19,12 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() { export default function DesktopRemoteScanScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput); const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
@@ -42,7 +44,7 @@ export default function DesktopRemoteScanScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Desktop Remote Desktop Remote
@@ -62,7 +64,7 @@ export default function DesktopRemoteScanScreen() {
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} /> <Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan the desktop pairing QR.</Text> <Text variant="body">Camera access is needed to scan the desktop pairing QR.</Text>
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}> <Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Allow camera Allow camera
</Text> </Text>
+8 -5
View File
@@ -20,6 +20,7 @@ import { Text } from '@/components/Text';
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails'; import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { formatRelativeTime } from '@/lib/format'; import { formatRelativeTime } from '@/lib/format';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
@@ -67,6 +68,7 @@ function ConflictCard({
onResolve: (resolution: DesktopSyncConflictResolution) => void; onResolve: (resolution: DesktopSyncConflictResolution) => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null); const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null);
const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote); const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
@@ -85,7 +87,7 @@ function ConflictCard({
</Text> </Text>
<View style={styles.conflictActions}> <View style={styles.conflictActions}>
{options.map((resolution) => ( {options.map((resolution) => (
<Pressable <Pressable android_ripple={ripple.bounded}
key={resolution} key={resolution}
style={[ style={[
styles.conflictBtn, styles.conflictBtn,
@@ -114,7 +116,7 @@ function ConflictCard({
previewResolution={selectedResolution} previewResolution={selectedResolution}
/> />
<View style={styles.conflictConfirmRow}> <View style={styles.conflictConfirmRow}>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]} style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
disabled={!selectedResolution || busy} disabled={!selectedResolution || busy}
onPress={() => selectedResolution ? onResolve(selectedResolution) : undefined} onPress={() => selectedResolution ? onResolve(selectedResolution) : undefined}
@@ -130,6 +132,7 @@ function ConflictCard({
export default function DesktopSyncScreen() { export default function DesktopSyncScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const status = useDesktopSyncStore((s) => s.status); const status = useDesktopSyncStore((s) => s.status);
@@ -172,7 +175,7 @@ export default function DesktopSyncScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.topBar}> <View style={styles.topBar}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Settings Settings
@@ -201,7 +204,7 @@ export default function DesktopSyncScreen() {
Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop
once and both features work. once and both features work.
</Text> </Text>
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.primaryButton} style={styles.primaryButton}
onPress={() => router.push('/desktop-remote' as never)} onPress={() => router.push('/desktop-remote' as never)}
> >
@@ -225,7 +228,7 @@ export default function DesktopSyncScreen() {
: 'Not synced yet'} : 'Not synced yet'}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.primaryButton, syncing && styles.disabled]} style={[styles.primaryButton, syncing && styles.disabled]}
disabled={syncing} disabled={syncing}
onPress={() => void syncNow()} onPress={() => void syncNow()}
+4 -2
View File
@@ -10,10 +10,12 @@ import { genEqId } from '@/audio/eqPresets';
import { useEQStore } from '@/stores/eqStore'; import { useEQStore } from '@/stores/eqStore';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { EQPreset } from '@/types/audio'; import type { EQPreset } from '@/types/audio';
export default function EQPresetImportScreen() { export default function EQPresetImportScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const importPreset = useEQStore((state) => state.importPreset); const importPreset = useEQStore((state) => state.importPreset);
@@ -34,7 +36,7 @@ export default function EQPresetImportScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={goToEq} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={goToEq} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Equalizer Equalizer
@@ -44,7 +46,7 @@ export default function EQPresetImportScreen() {
<View style={styles.errorCard}> <View style={styles.errorCard}>
<Ionicons name="alert-circle-outline" size={28} color={colors.warning} /> <Ionicons name="alert-circle-outline" size={28} color={colors.warning} />
<Text variant="body">This link does not contain a valid Astra EQ preset.</Text> <Text variant="body">This link does not contain a valid Astra EQ preset.</Text>
<Pressable style={styles.primaryButton} onPress={goToEq}> <Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={goToEq}>
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Go to Equalizer Go to Equalizer
</Text> </Text>
+5 -3
View File
@@ -22,10 +22,12 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { EQPreset } from '@/types/audio'; import type { EQPreset } from '@/types/audio';
export default function EQPresetScanScreen() { export default function EQPresetScanScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const importPreset = useEQStore((state) => state.importPreset); const importPreset = useEQStore((state) => state.importPreset);
@@ -49,7 +51,7 @@ export default function EQPresetScanScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Equalizer Equalizer
@@ -67,7 +69,7 @@ export default function EQPresetScanScreen() {
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} /> <Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan EQ preset QR codes.</Text> <Text variant="body">Camera access is needed to scan EQ preset QR codes.</Text>
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}> <Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Allow camera Allow camera
</Text> </Text>
@@ -90,7 +92,7 @@ export default function EQPresetScanScreen() {
{error} {error}
</Text> </Text>
</View> </View>
<Pressable style={styles.retryButton} onPress={() => setError(null)}> <Pressable android_ripple={ripple.bounded} style={styles.retryButton} onPress={() => setError(null)}>
<Text variant="label" color={colors.accentTextStrong}> <Text variant="label" color={colors.accentTextStrong}>
Scan again Scan again
</Text> </Text>
+6 -4
View File
@@ -19,6 +19,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm'; import type { LastFmScrobbleProtocol } from '@/types/lastFm';
@@ -103,6 +104,7 @@ function Field({
export default function LastFmEditScreen() { export default function LastFmEditScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>(); const { id } = useLocalSearchParams<{ id?: string }>();
@@ -200,7 +202,7 @@ export default function LastFmEditScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={goBack} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={goBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
{backLabel} {backLabel}
@@ -219,7 +221,7 @@ export default function LastFmEditScreen() {
<View style={styles.typeCards}> <View style={styles.typeCards}>
{PROTOCOL_OPTIONS.map((option) => ( {PROTOCOL_OPTIONS.map((option) => (
<Pressable <Pressable android_ripple={ripple.bounded}
key={option.protocol} key={option.protocol}
style={styles.typeCard} style={styles.typeCard}
onPress={() => chooseProtocol(option.protocol)} onPress={() => chooseProtocol(option.protocol)}
@@ -293,7 +295,7 @@ export default function LastFmEditScreen() {
</Text> </Text>
) : null} ) : null}
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.saveButton, !canSubmit || busy ? styles.buttonDisabled : null]} style={[styles.saveButton, !canSubmit || busy ? styles.buttonDisabled : null]}
onPress={() => void onSave()} onPress={() => void onSave()}
disabled={!canSubmit || busy} disabled={!canSubmit || busy}
@@ -308,7 +310,7 @@ export default function LastFmEditScreen() {
</Pressable> </Pressable>
{editing ? ( {editing ? (
<Pressable style={styles.removeButton} onPress={onRemove} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.removeButton} onPress={onRemove} accessibilityRole="button">
<Ionicons name="trash-outline" size={18} color={colors.warning} /> <Ionicons name="trash-outline" size={18} color={colors.warning} />
<Text variant="body" color={colors.warning}> <Text variant="body" color={colors.warning}>
Remove destination Remove destination
+9 -7
View File
@@ -16,6 +16,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm'; import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm'; import type { LastFmProfileStatus } from '@/types/lastFm';
@@ -45,6 +46,7 @@ function customStatusLine(profile: LastFmProfileStatus): { text: string; tone: '
export default function LastFmScreen() { export default function LastFmScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const status = useLastFmSettingsStore((s) => s.status); const status = useLastFmSettingsStore((s) => s.status);
@@ -125,7 +127,7 @@ export default function LastFmScreen() {
{profile.username} {profile.username}
</Text> </Text>
) : null} ) : null}
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={() => confirmDisconnect(profile)} onPress={() => confirmDisconnect(profile)}
hitSlop={8} hitSlop={8}
accessibilityLabel="Disconnect Last.fm" accessibilityLabel="Disconnect Last.fm"
@@ -134,7 +136,7 @@ export default function LastFmScreen() {
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
<Pressable <Pressable android_ripple={ripple.bounded}
style={styles.connectButton} style={styles.connectButton}
onPress={() => connectOfficial(profile)} onPress={() => connectOfficial(profile)}
accessibilityRole="button" accessibilityRole="button"
@@ -152,7 +154,7 @@ export default function LastFmScreen() {
const renderCustom = (profile: LastFmProfileStatus) => { const renderCustom = (profile: LastFmProfileStatus) => {
const line = customStatusLine(profile); const line = customStatusLine(profile);
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded}
key={profile.id} key={profile.id}
style={styles.row} style={styles.row}
onPress={() => router.push({ pathname: '/lastfm/edit', params: { id: profile.id } })} onPress={() => router.push({ pathname: '/lastfm/edit', params: { id: profile.id } })}
@@ -195,13 +197,13 @@ export default function LastFmScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Settings Settings
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={() => router.push('/lastfm/edit')} onPress={() => router.push('/lastfm/edit')}
hitSlop={8} hitSlop={8}
accessibilityLabel="Add destination" accessibilityLabel="Add destination"
@@ -249,7 +251,7 @@ export default function LastFmScreen() {
) : null} ) : null}
{status && status.pendingScrobbles > 0 ? ( {status && status.pendingScrobbles > 0 ? (
<Pressable style={styles.retryButton} onPress={() => requestLastFmFlush()}> <Pressable android_ripple={ripple.bounded} style={styles.retryButton} onPress={() => requestLastFmFlush()}>
<Ionicons name="sync" size={16} color={colors.accentText} /> <Ionicons name="sync" size={16} color={colors.accentText} />
<Text variant="label" color={colors.accentText}> <Text variant="label" color={colors.accentText}>
Retry {status.pendingScrobbles} queued now Retry {status.pendingScrobbles} queued now
@@ -271,7 +273,7 @@ export default function LastFmScreen() {
)} )}
</View> </View>
<Pressable style={styles.addButton} onPress={() => router.push('/lastfm/edit')}> <Pressable android_ripple={ripple.bounded} style={styles.addButton} onPress={() => router.push('/lastfm/edit')}>
<Ionicons name="add" size={18} color={colors.accentTextStrong} /> <Ionicons name="add" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Add destination Add destination
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { getNotificationClickRedirectPath } from '@/audio/notificationIntent'; import { resolveNotificationClick } from '@/audio/notificationIntent';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() { export default function NotificationClickRoute() {
@@ -11,7 +11,7 @@ export default function NotificationClickRoute() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
getNotificationClickRedirectPath() resolveNotificationClick()
.then((href) => { .then((href) => {
if (!cancelled) router.replace(href); if (!cancelled) router.replace(href);
}) })
+3 -1
View File
@@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController'; import { playTracks } from '@/audio/playbackController';
@@ -37,6 +38,7 @@ function EmptyList() {
export default function RecentlyPlayedScreen() { export default function RecentlyPlayedScreen() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const tracks = useLibraryStore((s) => s.recentlyPlayedTracks); const tracks = useLibraryStore((s) => s.recentlyPlayedTracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path); const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -49,7 +51,7 @@ export default function RecentlyPlayedScreen() {
return ( return (
<Screen> <Screen>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Home Home
+6 -4
View File
@@ -18,6 +18,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote'; import type { RemoteSourceType } from '@/types/remote';
@@ -84,6 +85,7 @@ function Field({
export default function SourceEditScreen() { export default function SourceEditScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>(); const { id } = useLocalSearchParams<{ id?: string }>();
@@ -187,7 +189,7 @@ export default function SourceEditScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={goBack} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={goBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
{backLabel} {backLabel}
@@ -206,7 +208,7 @@ export default function SourceEditScreen() {
<View style={styles.typeCards}> <View style={styles.typeCards}>
{TYPE_OPTIONS.map((option) => ( {TYPE_OPTIONS.map((option) => (
<Pressable <Pressable android_ripple={ripple.bounded}
key={option.type} key={option.type}
style={styles.typeCard} style={styles.typeCard}
onPress={() => chooseType(option.type)} onPress={() => chooseType(option.type)}
@@ -274,7 +276,7 @@ export default function SourceEditScreen() {
) : null} ) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.button, styles.secondaryButton, busy ? styles.buttonDisabled : null]} style={[styles.button, styles.secondaryButton, busy ? styles.buttonDisabled : null]}
onPress={() => void onTest()} onPress={() => void onTest()}
disabled={!!busy} disabled={!!busy}
@@ -287,7 +289,7 @@ export default function SourceEditScreen() {
</Text> </Text>
)} )}
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[ style={[
styles.button, styles.button,
styles.primaryButton, styles.primaryButton,
+7 -5
View File
@@ -16,6 +16,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote'; import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';
@@ -39,6 +40,7 @@ function statusLine(
export default function SourcesScreen() { export default function SourcesScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const sources = useRemoteSourcesStore((s) => s.sources); const sources = useRemoteSourcesStore((s) => s.sources);
@@ -103,7 +105,7 @@ export default function SourcesScreen() {
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Settings Settings
@@ -111,11 +113,11 @@ export default function SourcesScreen() {
</Pressable> </Pressable>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{sources.length > 0 ? ( {sources.length > 0 ? (
<Pressable onPress={() => void syncAll()} hitSlop={8} accessibilityLabel="Sync all"> <Pressable android_ripple={ripple.bounded} onPress={() => void syncAll()} hitSlop={8} accessibilityLabel="Sync all">
<Ionicons name="sync" size={20} color={colors.textSecondary} /> <Ionicons name="sync" size={20} color={colors.textSecondary} />
</Pressable> </Pressable>
) : null} ) : null}
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={() => router.push('/sources/edit')} onPress={() => router.push('/sources/edit')}
hitSlop={8} hitSlop={8}
accessibilityLabel="Add server" accessibilityLabel="Add server"
@@ -137,7 +139,7 @@ export default function SourcesScreen() {
No servers yet. Add a Subsonic or Jellyfin server to stream and browse your No servers yet. Add a Subsonic or Jellyfin server to stream and browse your
self-hosted library. self-hosted library.
</Text> </Text>
<Pressable style={styles.addButton} onPress={() => router.push('/sources/edit')}> <Pressable android_ripple={ripple.bounded} style={styles.addButton} onPress={() => router.push('/sources/edit')}>
<Ionicons name="add" size={18} color={colors.accentTextStrong} /> <Ionicons name="add" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}> <Text variant="body" color={colors.accentTextStrong}>
Add server Add server
@@ -148,7 +150,7 @@ export default function SourcesScreen() {
sources.map((source) => { sources.map((source) => {
const status = statusLine(source, progressById[source.id] ?? null); const status = statusLine(source, progressById[source.id] ?? null);
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded}
key={source.id} key={source.id}
style={styles.row} style={styles.row}
onPress={() => setActionFor(source)} onPress={() => setActionFor(source)}
+9 -4
View File
@@ -1,5 +1,6 @@
import TrackPlayer from 'react-native-track-player'; import TrackPlayer from 'react-native-track-player';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
const NOTIFICATION_CLICK_TARGETS = new Set([ const NOTIFICATION_CLICK_TARGETS = new Set([
'trackplayer://notification.click', 'trackplayer://notification.click',
@@ -9,8 +10,6 @@ const NOTIFICATION_CLICK_TARGETS = new Set([
'notification.click', 'notification.click',
]); ]);
export type NotificationClickRedirectPath = '/' | '/now-playing';
export function isNotificationClickPath(path: string): boolean { export function isNotificationClickPath(path: string): boolean {
const cleanPath = path.trim(); const cleanPath = path.trim();
const pathWithoutSuffix = cleanPath.split(/[?#]/, 1)[0]; const pathWithoutSuffix = cleanPath.split(/[?#]/, 1)[0];
@@ -31,8 +30,14 @@ export function isNotificationClickPath(path: string): boolean {
} }
} }
export async function getNotificationClickRedirectPath(): Promise<NotificationClickRedirectPath> { /**
return (await hasLoadedTrack()) ? '/now-playing' : '/'; * Notification/widget tap: land on the tabs and, when a track is loaded, open
* the now-playing overlay above them (the player is a store-gated overlay, not
* a route). Returns the route to navigate to.
*/
export async function resolveNotificationClick(): Promise<'/'> {
if (await hasLoadedTrack()) usePlayerUiStore.getState().openPlayer();
return '/';
} }
async function hasLoadedTrack(): Promise<boolean> { async function hasLoadedTrack(): Promise<boolean> {
+12 -9
View File
@@ -7,7 +7,7 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePathname, useRouter } from 'expo-router'; import { usePlayerUiStore } from '@/stores/playerUiStore';
import { Text } from './Text'; import { Text } from './Text';
import { AstraLogo } from './AstraLogo'; import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve'; import { SpectrumCurve } from './SpectrumCurve';
@@ -16,6 +16,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
@@ -72,8 +73,8 @@ function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) {
export function MiniPlayer({ visible = true }: MiniPlayerProps) { export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const ripple = useRipple();
const pathname = usePathname(); const playerOpen = usePlayerUiStore((s) => s.playerOpen);
const selectedTarget = usePlaybackTargetStore((s) => s.target); const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState); const playbackState = usePlayerStore((s) => s.playbackState);
@@ -107,9 +108,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const isDesktop = presentation.target === 'desktop'; const isDesktop = presentation.target === 'desktop';
const isPlaying = presentation.playbackState === 'playing'; const isPlaying = presentation.playbackState === 'playing';
const isLoading = presentation.playbackState === 'loading'; const isLoading = presentation.playbackState === 'loading';
// The pill sits underneath the now-playing transparentModal; don't burn a // The pill sits underneath the now-playing overlay; don't burn a second
// second live-scope frame loop while it's fully occluded. // live-scope frame loop while it's fully occluded.
const liveScopeActive = visible && scopeActive && !isDesktop && pathname !== '/now-playing'; const liveScopeActive = visible && scopeActive && !isDesktop && !playerOpen;
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
const onTogglePlay = () => { const onTogglePlay = () => {
@@ -135,8 +136,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
<> <>
<Pressable <Pressable
pointerEvents={visible ? 'auto' : 'none'} pointerEvents={visible ? 'auto' : 'none'}
android_ripple={ripple.bounded}
style={[styles.pill, !visible && styles.hidden]} style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')} onPress={() => usePlayerUiStore.getState().openPlayer()}
onLayout={onLayout} onLayout={onLayout}
> >
{liveScopeActive && pillWidth > 0 && ( {liveScopeActive && pillWidth > 0 && (
@@ -183,6 +185,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
{isDesktop ? ( {isDesktop ? (
<Pressable <Pressable
hitSlop={10} hitSlop={10}
android_ripple={ripple.icon(22)}
onPress={() => setTargetPickerOpen(true)} onPress={() => setTargetPickerOpen(true)}
style={styles.control} style={styles.control}
accessibilityLabel="Choose output device" accessibilityLabel="Choose output device"
@@ -190,14 +193,14 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} /> <Ionicons name="desktop-outline" size={21} color={colors.textSecondary} />
</Pressable> </Pressable>
) : null} ) : null}
<Pressable hitSlop={10} onPress={onTogglePlay} style={styles.control}> <Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onTogglePlay} style={styles.control}>
<Ionicons <Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'} name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={24} size={24}
color={colors.accent} color={colors.accent}
/> />
</Pressable> </Pressable>
<Pressable hitSlop={10} onPress={onSkipNext} style={styles.control}> <Pressable hitSlop={10} android_ripple={ripple.icon(22)} onPress={onSkipNext} style={styles.control}>
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} /> <Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
</Pressable> </Pressable>
</View> </View>
+4 -4
View File
@@ -10,6 +10,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text'; import { Text } from './Text';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -118,9 +119,11 @@ function TargetRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<Pressable <Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} android_ripple={ripple.bounded}
style={styles.row}
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ selected }} accessibilityState={{ selected }}
@@ -176,9 +179,6 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
gap: spacing.md, gap: spacing.md,
}, },
rowPressed: {
opacity: 0.65,
},
iconWrap: { iconWrap: {
width: 40, width: 40,
height: 40, height: 40,
+4 -4
View File
@@ -16,6 +16,7 @@ import {
radius, radius,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
const THUMB_INSET = 3; const THUMB_INSET = 3;
@@ -89,6 +90,7 @@ function SegmentButton({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
// 0 = inactive, 1 = active; drives the label colour cross-fade. // 0 = inactive, 1 = active; drives the label colour cross-fade.
const progress = useSharedValue(focused ? 1 : 0); const progress = useSharedValue(focused ? 1 : 0);
@@ -106,7 +108,8 @@ function SegmentButton({
return ( return (
<Pressable <Pressable
style={({ pressed }) => [styles.segment, pressed && styles.segmentPressed]} android_ripple={ripple.bounded}
style={styles.segment}
onPress={onPress} onPress={onPress}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityState={{ selected: focused }} accessibilityState={{ selected: focused }}
@@ -143,9 +146,6 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 7, paddingVertical: 7,
}, },
segmentPressed: {
opacity: 0.72,
},
label: { label: {
fontSize: 12, fontSize: 12,
fontFamily: fonts.sans.medium, fontFamily: fonts.sans.medium,
+3
View File
@@ -20,6 +20,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
@@ -161,6 +162,7 @@ interface TabButtonProps {
function TabButton({ meta, focused, onPress }: TabButtonProps) { function TabButton({ meta, focused, onPress }: TabButtonProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
// 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom. // 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom.
const progress = useSharedValue(focused ? 1 : 0); const progress = useSharedValue(focused ? 1 : 0);
// 0 = at rest, 1 = finger down. // 0 = at rest, 1 = finger down.
@@ -184,6 +186,7 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) {
return ( return (
<Pressable <Pressable
android_ripple={ripple.icon(26)}
style={styles.tab} style={styles.tab}
onPress={onPress} onPress={onPress}
onPressIn={() => { onPressIn={() => {
+8 -2
View File
@@ -36,6 +36,11 @@ interface WaveformSeekBarProps {
touchPadding?: number; touchPadding?: number;
/** Track file URI used to load/cache the offline waveform peaks. */ /** Track file URI used to load/cache the offline waveform peaks. */
trackPath?: string; trackPath?: string;
/**
* False while the bar is mounted but hidden (the now-playing overlay stays
* mounted when closed): pins progress and stops the smooth-time rAF loop.
*/
active?: boolean;
} }
const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction)); const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
@@ -54,12 +59,13 @@ export function WaveformSeekBar({
height = CANVAS_HEIGHT, height = CANVAS_HEIGHT,
touchPadding = spacing.md, touchPadding = spacing.md,
trackPath, trackPath,
active = true,
}: WaveformSeekBarProps) { }: WaveformSeekBarProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const currentTime = usePlayerStore((s) => s.currentTime); const currentTime = usePlayerStore((s) => (active ? s.currentTime : 0));
const duration = usePlayerStore((s) => s.duration); const duration = usePlayerStore((s) => s.duration);
const isPlaying = usePlayerStore((s) => s.playbackState === 'playing'); const isPlaying = usePlayerStore((s) => active && s.playbackState === 'playing');
const [scrubFraction, setScrubFraction] = useState<number | null>(null); const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0); const [barWidth, setBarWidth] = useState(0);
const pendingSeek = usePlayerStore((s) => s.pendingSeek); const pendingSeek = usePlayerStore((s) => s.pendingSeek);
+3 -1
View File
@@ -11,6 +11,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { EQBand } from '@/types/audio'; import type { EQBand } from '@/types/audio';
import { import {
EQ_MAX_FREQUENCY, EQ_MAX_FREQUENCY,
@@ -43,6 +44,7 @@ export type EQEditableValue = 'frequency' | 'gain' | 'Q';
/** "Band N" + type dropdown + On toggle + audible parameter sliders. */ /** "Band N" + type dropdown + On toggle + audible parameter sliders. */
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) { export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
if (!band) { if (!band) {
return ( return (
@@ -61,7 +63,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
<View style={styles.card}> <View style={styles.card}>
<View style={styles.header}> <View style={styles.header}>
<Text variant="heading">Band {bandNumber}</Text> <Text variant="heading">Band {bandNumber}</Text>
<Pressable style={styles.typeButton} onPress={onEditType}> <Pressable android_ripple={ripple.bounded} style={styles.typeButton} onPress={onEditType}>
<Text variant="label" color={colors.textPrimary}> <Text variant="label" color={colors.textPrimary}>
{BAND_TYPE_LABEL[band.type]} {BAND_TYPE_LABEL[band.type]}
</Text> </Text>
+4 -2
View File
@@ -10,6 +10,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import type { EQBand } from '@/types/audio'; import type { EQBand } from '@/types/audio';
import { import {
formatFreq, formatFreq,
@@ -28,6 +29,7 @@ interface BandStripProps {
/** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */ /** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */
export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) { export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<ScrollView <ScrollView
@@ -38,7 +40,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band
{bands.map((band) => { {bands.map((band) => {
const isActive = band.id === activeBandId; const isActive = band.id === activeBandId;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={band.id} key={band.id}
onPress={() => onSelect(band.id)} onPress={() => onSelect(band.id)}
style={[styles.cell, isActive && styles.cellActive]} style={[styles.cell, isActive && styles.cellActive]}
@@ -56,7 +58,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band
); );
})} })}
{canAdd ? ( {canAdd ? (
<Pressable onPress={onAdd} style={[styles.cell, styles.addCell]} accessibilityLabel="Add band"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onAdd} style={[styles.cell, styles.addCell]} accessibilityLabel="Add band">
<Ionicons name="add" size={22} color={colors.accentText} /> <Ionicons name="add" size={22} color={colors.accentText} />
</Pressable> </Pressable>
) : null} ) : null}
+4 -2
View File
@@ -12,6 +12,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { EqSheet } from './EqSheet'; import { EqSheet } from './EqSheet';
interface EQPresetNameSheetProps { interface EQPresetNameSheetProps {
@@ -28,6 +29,7 @@ export function EQPresetNameSheet({
onClose, onClose,
}: EQPresetNameSheetProps) { }: EQPresetNameSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [name, setName] = useState(defaultName); const [name, setName] = useState(defaultName);
const trimmed = name.trim(); const trimmed = name.trim();
@@ -56,12 +58,12 @@ export function EQPresetNameSheet({
onSubmitEditing={submit} onSubmitEditing={submit}
/> />
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable style={[styles.btn, styles.primary, !trimmed && styles.disabled]} disabled={!trimmed} onPress={submit}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.primary, !trimmed && styles.disabled]} disabled={!trimmed} onPress={submit}>
<Text variant="label" color={colors.accentTextStrong}> <Text variant="label" color={colors.accentTextStrong}>
{actionLabel} {actionLabel}
</Text> </Text>
+4 -2
View File
@@ -9,6 +9,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { EQPreset } from '@/types/audio'; import type { EQPreset } from '@/types/audio';
import { EqSheet } from './EqSheet'; import { EqSheet } from './EqSheet';
import { formatGain } from './format'; import { formatGain } from './format';
@@ -29,6 +30,7 @@ export function EQPresetPreviewSheet({
onClose, onClose,
}: EQPresetPreviewSheetProps) { }: EQPresetPreviewSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const enabledBands = preset.bands.filter((band) => band.enabled).length; const enabledBands = preset.bands.filter((band) => band.enabled).length;
const modeLabel = preset.mode === 'graphic' ? 'Graphic' : 'Parametric'; const modeLabel = preset.mode === 'graphic' ? 'Graphic' : 'Parametric';
@@ -68,12 +70,12 @@ export function EQPresetPreviewSheet({
</View> </View>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.btn, styles.primary]} style={[styles.btn, styles.primary]}
onPress={() => { onPress={() => {
onConfirm(); onConfirm();
+3 -1
View File
@@ -10,6 +10,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { EqSheet } from './EqSheet'; import { EqSheet } from './EqSheet';
interface EQPresetQrSheetProps { interface EQPresetQrSheetProps {
@@ -20,6 +21,7 @@ interface EQPresetQrSheetProps {
export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetProps) { export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
@@ -33,7 +35,7 @@ export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetP
<Text variant="label" numberOfLines={1} color={colors.textSecondary} style={styles.name}> <Text variant="label" numberOfLines={1} color={colors.textSecondary} style={styles.name}>
{presetName} {presetName}
</Text> </Text>
<Pressable style={styles.done} onPress={onClose}> <Pressable android_ripple={ripple.bounded} style={styles.done} onPress={onClose}>
<Text variant="label" color={colors.accentTextStrong}> <Text variant="label" color={colors.accentTextStrong}>
Done Done
</Text> </Text>
+5
View File
@@ -12,6 +12,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
const THUMB = 16; const THUMB = 16;
@@ -43,6 +44,7 @@ export function EQSlider({
onValuePress, onValuePress,
}: EQSliderProps) { }: EQSliderProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const widthRef = useRef(0); const widthRef = useRef(0);
@@ -119,6 +121,9 @@ export function EQSlider({
</View> </View>
{onValuePress && !disabled ? ( {onValuePress && !disabled ? (
<Pressable <Pressable
android_ripple={ripple.bounded}
// Deliberate two-property pressed emphasis (accent border + fill), kept
// alongside the ripple — it marks the value as editable, not just pressed.
style={({ pressed }) => [styles.valueButton, pressed && styles.valueButtonPressed]} style={({ pressed }) => [styles.valueButton, pressed && styles.valueButtonPressed]}
onPress={onValuePress} onPress={onValuePress}
accessibilityRole="button" accessibilityRole="button"
+4 -2
View File
@@ -13,6 +13,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { EqSheet } from './EqSheet'; import { EqSheet } from './EqSheet';
interface EQValueEditSheetProps { interface EQValueEditSheetProps {
@@ -40,6 +41,7 @@ export function EQValueEditSheet({
onClose, onClose,
}: EQValueEditSheetProps) { }: EQValueEditSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const trimmed = value.trim(); const trimmed = value.trim();
@@ -80,12 +82,12 @@ export function EQValueEditSheet({
{valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'} {valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'}
</Text> </Text>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.btn, styles.apply, !valid && styles.applyDisabled]} style={[styles.btn, styles.apply, !valid && styles.applyDisabled]}
disabled={!valid} disabled={!valid}
onPress={apply} onPress={apply}
+3 -1
View File
@@ -3,6 +3,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import type { EQPreset } from '@/types/audio'; import type { EQPreset } from '@/types/audio';
import { import {
EqSheet, EqSheet,
@@ -37,6 +38,7 @@ export function PresetSheet({
onClose, onClose,
}: PresetSheetProps) { }: PresetSheetProps) {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const builtIn = presets.filter((p) => !p.isCustom); const builtIn = presets.filter((p) => !p.isCustom);
const custom = presets.filter((p) => p.isCustom); const custom = presets.filter((p) => p.isCustom);
@@ -76,7 +78,7 @@ export function PresetSheet({
onClose(); onClose();
}} }}
trailing={ trailing={
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={10} hitSlop={10}
onPress={() => onDelete(p.id)} onPress={() => onDelete(p.id)}
style={styles.delete} style={styles.delete}
+4 -2
View File
@@ -12,6 +12,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { EqSheet } from './EqSheet'; import { EqSheet } from './EqSheet';
interface SavePresetSheetProps { interface SavePresetSheetProps {
@@ -23,6 +24,7 @@ interface SavePresetSheetProps {
/** Name + save a custom preset from the current bands/preamp. */ /** Name + save a custom preset from the current bands/preamp. */
export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) { export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [name, setName] = useState(defaultName); const [name, setName] = useState(defaultName);
const trimmed = name.trim(); const trimmed = name.trim();
@@ -50,12 +52,12 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee
}} }}
/> />
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]} style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
disabled={!trimmed} disabled={!trimmed}
onPress={() => { onPress={() => {
+3 -1
View File
@@ -11,14 +11,16 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { albumArtworkSource } from '@/library/artwork'; import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library'; import type { Album } from '@/types/library';
export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) { export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const artUri = albumArtworkSource(album); const artUri = albumArtworkSource(album);
return ( return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}> <View style={styles.art}>
{artUri ? ( {artUri ? (
<Image <Image
+3 -1
View File
@@ -11,6 +11,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { albumArtworkSource } from '@/library/artwork'; import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library'; import type { Album } from '@/types/library';
@@ -18,9 +19,10 @@ import type { Album } from '@/types/library';
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) { export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const artUri = albumArtworkSource(album); const artUri = albumArtworkSource(album);
return ( return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}> <View style={styles.art}>
{artUri ? ( {artUri ? (
<Image <Image
+3 -1
View File
@@ -11,6 +11,7 @@ import {
radius, radius,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { artworkUri } from '@/library/artwork'; import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library'; import type { Artist } from '@/types/library';
@@ -18,6 +19,7 @@ import type { Artist } from '@/types/library';
export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) { export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const useMosaic = artist.artwork_hashes.length >= 4; const useMosaic = artist.artwork_hashes.length >= 4;
const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1); const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1);
@@ -25,7 +27,7 @@ export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: (
const tracks = `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`; const tracks = `${artist.track_count} ${artist.track_count === 1 ? 'track' : 'tracks'}`;
return ( return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.tile} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.item} onPress={onPress} accessibilityRole="button">
<View style={styles.art}> <View style={styles.art}>
{hashes.length === 0 ? ( {hashes.length === 0 ? (
<Ionicons name="person" size={44} color={colors.textTertiary} /> <Ionicons name="person" size={44} color={colors.textTertiary} />
+3 -1
View File
@@ -11,14 +11,16 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { artworkUri } from '@/library/artwork'; import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library'; import type { Artist } from '@/types/library';
export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) { export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}> <View style={styles.art}>
{artist.artwork_hash ? ( {artist.artwork_hash ? (
<Image <Image
+8 -6
View File
@@ -34,6 +34,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
// Collapsing detail header. An absolute container whose height shrinks with the // Collapsing detail header. An absolute container whose height shrinks with the
// scroll and clips its faded content, so the track list (padded to the expanded // scroll and clips its faded content, so the track list (padded to the expanded
@@ -155,6 +156,7 @@ export function CollapsingHeader({
onHeroBlockLayout: (e: LayoutChangeEvent) => void; onHeroBlockLayout: (e: LayoutChangeEvent) => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: W } = useWindowDimensions(); const { width: W } = useWindowDimensions();
@@ -246,7 +248,7 @@ export function CollapsingHeader({
{heroMeta} {heroMeta}
{heroExtra} {heroExtra}
<Animated.View style={[styles.actionRow, heroButtonsStyle]}> <Animated.View style={[styles.actionRow, heroButtonsStyle]}>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]} style={[styles.actionButton, styles.primaryAction, disabled && styles.disabledAction]}
onPress={onPlay} onPress={onPlay}
disabled={disabled} disabled={disabled}
@@ -257,7 +259,7 @@ export function CollapsingHeader({
Play Play
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]} style={[styles.actionButton, styles.secondaryAction, disabled && styles.disabledAction]}
onPress={onShuffle} onPress={onShuffle}
disabled={disabled} disabled={disabled}
@@ -271,7 +273,7 @@ export function CollapsingHeader({
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onBack} onPress={onBack}
hitSlop={8} hitSlop={8}
style={[styles.chevron, { top: barCenterY - 12, left: spacing.md }]} style={[styles.chevron, { top: barCenterY - 12, left: spacing.md }]}
@@ -299,16 +301,16 @@ export function CollapsingHeader({
style={[styles.barIcons, { top: barCenterY - 16, right: onMore ? spacing.md + 40 : spacing.md }, barIconsStyle]} style={[styles.barIcons, { top: barCenterY - 16, right: onMore ? spacing.md + 40 : spacing.md }, barIconsStyle]}
pointerEvents={collapsed ? 'auto' : 'none'} pointerEvents={collapsed ? 'auto' : 'none'}
> >
<Pressable onPress={onPlay} disabled={disabled} hitSlop={6} style={styles.iconBtn}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onPlay} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="play" size={20} color={colors.accent} /> <Ionicons name="play" size={20} color={colors.accent} />
</Pressable> </Pressable>
<Pressable onPress={onShuffle} disabled={disabled} hitSlop={6} style={styles.iconBtn}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onShuffle} disabled={disabled} hitSlop={6} style={styles.iconBtn}>
<Ionicons name="shuffle" size={20} color={colors.accent} /> <Ionicons name="shuffle" size={20} color={colors.accent} />
</Pressable> </Pressable>
</Animated.View> </Animated.View>
{onMore ? ( {onMore ? (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={onMore} onPress={onMore}
hitSlop={8} hitSlop={8}
style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]} style={[styles.moreButton, { top: barCenterY - 16, right: spacing.md }]}
+3 -1
View File
@@ -10,9 +10,11 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
export function EmptyLibrary() { export function EmptyLibrary() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
@@ -25,7 +27,7 @@ export function EmptyLibrary() {
<Text variant="body" color={colors.textSecondary} style={styles.body}> <Text variant="body" color={colors.textSecondary} style={styles.body}>
Pick a folder on this device and Astra will scan it into your library. Pick a folder on this device and Astra will scan it into your library.
</Text> </Text>
<Pressable style={styles.cta} onPress={() => router.push('/settings')} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.cta} onPress={() => router.push('/settings')} accessibilityRole="button">
<Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} /> <Ionicons name="folder-open-outline" size={18} color={colors.bgPrimary} />
<Text variant="body" style={styles.ctaLabel}> <Text variant="body" style={styles.ctaLabel}>
Folder settings Folder settings
+13 -18
View File
@@ -36,6 +36,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
@@ -60,6 +61,7 @@ function FolderRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const { node, depth, isExpanded } = row; const { node, depth, isExpanded } = row;
const play = (event: GestureResponderEvent) => { const play = (event: GestureResponderEvent) => {
@@ -73,7 +75,8 @@ function FolderRow({
return ( return (
<Pressable <Pressable
style={({ pressed }) => [styles.folderRow, pressed && styles.rowPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.folderRow}
onPress={() => onToggle(node.id)} onPress={() => onToggle(node.id)}
onLongPress={() => onOpenActions(node)} onLongPress={() => onOpenActions(node)}
accessibilityRole="button" accessibilityRole="button"
@@ -104,7 +107,8 @@ function FolderRow({
{node.totalTrackCount} {node.totalTrackCount}
</Text> </Text>
<Pressable <Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.folderButton}
onPress={play} onPress={play}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -113,7 +117,8 @@ function FolderRow({
<Ionicons name="play" size={16} color={colors.accent} /> <Ionicons name="play" size={16} color={colors.accent} />
</Pressable> </Pressable>
<Pressable <Pressable
style={({ pressed }) => [styles.folderButton, pressed && styles.folderButtonPressed]} android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.folderButton}
onPress={shuffle} onPress={shuffle}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
@@ -136,6 +141,7 @@ function FolderTrackRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const index = row.folderTracks.findIndex((track) => track.path === row.track.path); const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
const playFolderTrack = () => { const playFolderTrack = () => {
@@ -148,11 +154,8 @@ function FolderTrackRow({
return ( return (
<Pressable <Pressable
style={({ pressed }) => [ android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
styles.trackRow, style={[styles.trackRow, active && styles.trackRowActive]}
active && styles.trackRowActive,
pressed && styles.rowPressed,
]}
onPress={playFolderTrack} onPress={playFolderTrack}
onLongPress={onOpenActions} onLongPress={onOpenActions}
accessibilityRole="button" accessibilityRole="button"
@@ -171,7 +174,8 @@ function FolderTrackRow({
{formatDuration(row.track.duration)} {formatDuration(row.track.duration)}
</Text> </Text>
<Pressable <Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]} android_ripple={ripple.icon(21)} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.actionsButton}
onPress={openActions} onPress={openActions}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
@@ -347,9 +351,6 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
folderButtonPressed: {
backgroundColor: colors.glassBg,
},
trackRow: { trackRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -362,9 +363,6 @@ const useStyles = createThemedStyles((colors) => ({
trackRowActive: { trackRowActive: {
backgroundColor: colors.accentGlow, backgroundColor: colors.accentGlow,
}, },
rowPressed: {
opacity: 0.72,
},
trackMeta: { trackMeta: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
@@ -390,9 +388,6 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
actionsButtonPressed: {
backgroundColor: colors.glassBg,
},
empty: { empty: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
+3
View File
@@ -11,6 +11,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { artworkUri } from '@/library/artwork'; import { artworkUri } from '@/library/artwork';
export function PlaylistRow({ export function PlaylistRow({
@@ -39,8 +40,10 @@ export function PlaylistRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<Pressable <Pressable
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.row} style={styles.row}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
+3 -1
View File
@@ -24,6 +24,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist'; import type { Playlist } from '@/types/playlist';
@@ -47,6 +48,7 @@ export function PlaylistsView({
scrollEventThrottle?: number; scrollEventThrottle?: number;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists); const playlists = usePlaylistStore((s) => s.playlists);
@@ -205,7 +207,7 @@ export function PlaylistsView({
/> />
<View style={styles.addBar}> <View style={styles.addBar}>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.addButton} style={styles.addButton}
onPress={() => setAddSheetOpen(true)} onPress={() => setAddSheetOpen(true)}
accessibilityRole="button" accessibilityRole="button"
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
interface SelectionActionBarProps { interface SelectionActionBarProps {
count: number; count: number;
@@ -66,13 +67,11 @@ function BarButton({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<Pressable <Pressable
style={({ pressed }) => [ android_ripple={ripple.bounded}
styles.button, style={[styles.button, disabled && styles.buttonDisabled]}
pressed && styles.buttonPressed,
disabled && styles.buttonDisabled,
]}
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
accessibilityRole="button" accessibilityRole="button"
@@ -101,9 +100,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.xs, gap: spacing.xs,
paddingVertical: spacing.md, paddingVertical: spacing.md,
}, },
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: { buttonDisabled: {
opacity: 0.4, opacity: 0.4,
}, },
+5 -4
View File
@@ -17,6 +17,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { trackArtworkThumbSource } from '@/library/artwork'; import { trackArtworkThumbSource } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter'; import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -60,6 +61,7 @@ export function TrackRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
// Key the artwork by hash (local) or identity path (remote) so the error fallback // Key the artwork by hash (local) or identity path (remote) so the error fallback
// and FlashList recycling work for both. // and FlashList recycling work for both.
const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash; const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash;
@@ -74,6 +76,7 @@ export function TrackRow({
const row = ( const row = (
<Pressable <Pressable
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.row, selectionMode && selected && styles.rowSelected]} style={[styles.row, selectionMode && selected && styles.rowSelected]}
onPress={selectionMode ? onToggleSelect : onPress} onPress={selectionMode ? onToggleSelect : onPress}
onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)} onLongPress={selectionMode ? onToggleSelect : (onLongPress ?? onOpenActions)}
@@ -143,7 +146,8 @@ export function TrackRow({
{onOpenActions && !selectionMode ? ( {onOpenActions && !selectionMode ? (
<Pressable <Pressable
style={({ pressed }) => [styles.actionsButton, pressed && styles.actionsButtonPressed]} android_ripple={ripple.icon(ACTIONS_BUTTON / 2 + 4)} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.actionsButton}
onPress={openActions} onPress={openActions}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
@@ -257,7 +261,4 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
actionsButtonPressed: {
backgroundColor: colors.glassBg,
},
})); }));
+3 -1
View File
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native'; import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { useLyricsStore } from '@/stores/lyricsStore'; import { useLyricsStore } from '@/stores/lyricsStore';
import { import {
@@ -44,6 +45,7 @@ function clamp(value: number, min: number, max: number): number {
export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) { export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const entry = useLyricsStore((s) => s.byPath[track.path]); const entry = useLyricsStore((s) => s.byPath[track.path]);
const loadForTrack = useLyricsStore((s) => s.loadForTrack); const loadForTrack = useLyricsStore((s) => s.loadForTrack);
@@ -228,7 +230,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
</ScrollView> </ScrollView>
{followPaused ? ( {followPaused ? (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={recenter} onPress={recenter}
hitSlop={10} hitSlop={10}
style={{ style={{
+3 -1
View File
@@ -20,6 +20,7 @@ import { Pressable, View, type LayoutChangeEvent } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { getPreferredLyricsTranslation } from '@/lyrics/presentation'; import { getPreferredLyricsTranslation } from '@/lyrics/presentation';
import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types'; import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types';
@@ -68,6 +69,7 @@ function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Se
function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) { function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) {
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const target = TIER[tier]; const target = TIER[tier];
// Uniform metrics for every line (the whole point — no wrap/reflow between tiers). // Uniform metrics for every line (the whole point — no wrap/reflow between tiers).
const size = baseSize; const size = baseSize;
@@ -100,7 +102,7 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek
return ( return (
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}> <Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
<Pressable onPress={onSeek} style={{ paddingVertical: 7 }}> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onSeek} style={{ paddingVertical: 7 }}>
<View style={{ alignItems: 'flex-start' }}> <View style={{ alignItems: 'flex-start' }}>
{hasFurigana ? ( {hasFurigana ? (
<View <View
+14 -8
View File
@@ -15,6 +15,7 @@ import { SeekBar } from '@/components/SeekBar';
import { LyricsBand } from './LyricsBand'; import { LyricsBand } from './LyricsBand';
import { spacing, radius } from '@/theme'; import { spacing, radius } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useLyricsStore } from '@/stores/lyricsStore'; import { useLyricsStore } from '@/stores/lyricsStore';
import { getLyricsPayloadSourceLabel } from '@/lyrics/presentation'; import { getLyricsPayloadSourceLabel } from '@/lyrics/presentation';
@@ -22,6 +23,8 @@ import type { Track } from '@/types/audio';
interface LyricsViewProps { interface LyricsViewProps {
track: Track; track: Track;
/** False while mounted but hidden (closed now-playing overlay): pins progress, stops rAF loops. */
active?: boolean;
isPlaying: boolean; isPlaying: boolean;
isLoading: boolean; isLoading: boolean;
isFavorite: boolean; isFavorite: boolean;
@@ -36,6 +39,7 @@ interface LyricsViewProps {
export function LyricsView({ export function LyricsView({
track, track,
active = true,
isPlaying, isPlaying,
isLoading, isLoading,
isFavorite, isFavorite,
@@ -48,10 +52,12 @@ export function LyricsView({
onDismiss, onDismiss,
}: LyricsViewProps) { }: LyricsViewProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
// Lyrics mode is phone-target only, so progress comes straight from the // Lyrics mode is phone-target only, so progress comes straight from the
// player store — the 2Hz tick re-renders this takeover, not the whole screen. // player store — the 2Hz tick re-renders this takeover, not the whole screen.
const currentTime = usePlayerStore((s) => s.currentTime); // While inactive the selector pins to 0 so hidden ticks don't re-render.
const currentTime = usePlayerStore((s) => (active ? s.currentTime : 0));
const duration = usePlayerStore((s) => s.duration); const duration = usePlayerStore((s) => s.duration);
const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null); const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null);
const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null; const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null;
@@ -59,7 +65,7 @@ export function LyricsView({
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.strip}> <View style={styles.strip}>
<Pressable onPress={onDismiss} hitSlop={12} style={styles.stripBtn} accessibilityLabel="Close player"> <Pressable android_ripple={ripple.bounded} onPress={onDismiss} hitSlop={12} style={styles.stripBtn} accessibilityLabel="Close player">
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} /> <Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
@@ -87,7 +93,7 @@ export function LyricsView({
</View> </View>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={onToggleFavorite} onPress={onToggleFavorite}
hitSlop={10} hitSlop={10}
style={styles.stripBtn} style={styles.stripBtn}
@@ -101,7 +107,7 @@ export function LyricsView({
/> />
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={onExitLyrics} onPress={onExitLyrics}
hitSlop={10} hitSlop={10}
style={styles.stripBtn} style={styles.stripBtn}
@@ -116,24 +122,24 @@ export function LyricsView({
track={track} track={track}
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
isPlaying={isPlaying} isPlaying={isPlaying && active}
onSeek={onSeek} onSeek={onSeek}
/> />
<View style={styles.controls}> <View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} /> <SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
<View style={styles.transport}> <View style={styles.transport}>
<Pressable onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous"> <Pressable android_ripple={ripple.bounded} onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} /> <Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
</Pressable> </Pressable>
<Pressable onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}> <Pressable android_ripple={ripple.bounded} onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<Ionicons <Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'} name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={28} size={28}
color={colors.bgPrimary} color={colors.bgPrimary}
/> />
</Pressable> </Pressable>
<Pressable onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next"> <Pressable android_ripple={ripple.bounded} onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} /> <Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
</Pressable> </Pressable>
</View> </View>
+8 -4
View File
@@ -26,6 +26,7 @@ import { formatFolderCount, formatTrackCount } from '@/components/settings/Setti
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { BaseThemeId } from '@/theme/resolve'; import type { BaseThemeId } from '@/theme/resolve';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { useThemeStore } from '@/stores/themeStore'; import { useThemeStore } from '@/stores/themeStore';
@@ -51,6 +52,7 @@ const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
*/ */
export function OnboardingFlow({ onDone }: { onDone: () => void }) { export function OnboardingFlow({ onDone }: { onDone: () => void }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
@@ -124,7 +126,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
</View> </View>
<View style={styles.navRow}> <View style={styles.navRow}>
{canGoBack ? ( {canGoBack ? (
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={goBack} onPress={goBack}
style={styles.secondaryButton} style={styles.secondaryButton}
accessibilityRole="button" accessibilityRole="button"
@@ -135,7 +137,7 @@ export function OnboardingFlow({ onDone }: { onDone: () => void }) {
</Text> </Text>
</Pressable> </Pressable>
) : null} ) : null}
<Pressable <Pressable android_ripple={ripple.bounded}
onPress={goNext} onPress={goNext}
style={styles.primaryButton} style={styles.primaryButton}
accessibilityRole="button" accessibilityRole="button"
@@ -174,6 +176,7 @@ function WelcomeStep() {
function LibraryStep() { function LibraryStep() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const folders = useLibraryStore((s) => s.folders); const folders = useLibraryStore((s) => s.folders);
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
@@ -187,7 +190,7 @@ function LibraryStep() {
title="Add your music" title="Add your music"
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified." subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
/> />
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.choiceButton, isScanning && styles.disabled]} style={[styles.choiceButton, isScanning && styles.disabled]}
disabled={isScanning} disabled={isScanning}
onPress={() => void addFolder()} onPress={() => void addFolder()}
@@ -224,6 +227,7 @@ function LibraryStep() {
function ThemeStep() { function ThemeStep() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme); const baseTheme = useThemeStore((s) => s.baseTheme);
const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable); const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
@@ -248,7 +252,7 @@ function ThemeStep() {
{options.map((option) => { {options.map((option) => {
const selected = option.id === baseTheme; const selected = option.id === baseTheme;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded}
key={option.id} key={option.id}
onPress={() => void setBaseTheme(option.id)} onPress={() => void setBaseTheme(option.id)}
style={[styles.themePill, selected && styles.themePillSelected]} style={[styles.themePill, selected && styles.themePillSelected]}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { usePlayerStore } from '@/stores/playerStore';
const PREWARM_DELAY_MS = 2000;
/**
* Mount gate for the always-mounted now-playing overlay. Nothing mounts until a
* track exists (cold start unchanged); shortly after playback first starts the
* overlay pre-warms hidden so even the FIRST open is a pure slide, no mount cost.
*/
export function NowPlayingHost() {
const everOpened = usePlayerUiStore((s) => s.everOpened);
const hasTrack = usePlayerStore((s) => Boolean(s.currentTrack));
useEffect(() => {
if (everOpened || !hasTrack) return;
const timer = setTimeout(() => usePlayerUiStore.getState().prewarm(), PREWARM_DELAY_MS);
return () => clearTimeout(timer);
}, [everOpened, hasTrack]);
if (!everOpened) return null;
return <NowPlayingOverlay />;
}
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
BackHandler,
View, View,
Pressable, Pressable,
StyleSheet, StyleSheet,
@@ -11,7 +12,6 @@ import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { import Animated, {
SlideInDown,
runOnJS, runOnJS,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -37,6 +37,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive'; import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
import { resolveNavigationArtist } from '@/library/artistGrouping'; import { resolveNavigationArtist } from '@/library/artistGrouping';
@@ -46,6 +47,7 @@ import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
import { import {
@@ -266,12 +268,14 @@ function getNowPlayingLayout(
}; };
} }
export default function NowPlayingScreen() { export function NowPlayingOverlay() {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const playerOpen = usePlayerUiStore((s) => s.playerOpen);
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => setQueueOpen(false), []); const closeQueue = useCallback(() => setQueueOpen(false), []);
@@ -353,7 +357,9 @@ export default function NowPlayingScreen() {
const navigateToArtist = () => { const navigateToArtist = () => {
if (!artistName) return; if (!artistName) return;
router.dismissTo({ // Slide the overlay away while the library detail loads underneath.
dismissSheet();
router.navigate({
pathname: '/library/artist/[name]', pathname: '/library/artist/[name]',
params: { name: artistName }, params: { name: artistName },
}); });
@@ -361,7 +367,8 @@ export default function NowPlayingScreen() {
const navigateToAlbum = () => { const navigateToAlbum = () => {
if (!albumKey) return; if (!albumKey) return;
router.dismissTo({ dismissSheet();
router.navigate({
pathname: '/library/album/[key]', pathname: '/library/album/[key]',
params: { key: albumKey }, params: { key: albumKey },
}); });
@@ -411,18 +418,16 @@ export default function NowPlayingScreen() {
}); });
} }
// Swipe down to minimize. The stack transition is disabled for this route, so // The overlay stays mounted; open/close is this one shared value sliding the
// the sheet owns one continuous enter/exit animation instead of handing off to // sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes.
// a second native modal animation after release. const translateY = useSharedValue(windowHeight);
const translateY = useSharedValue(0);
const menuProgress = useSharedValue(0); const menuProgress = useSharedValue(0);
// Belt-and-suspenders for deep-link entry (widget/notification → now-playing with no // Closing is a store toggle, not navigation. Reset the inner layers so a
// history): `(tabs)` is the stack anchor (see root _layout unstable_settings), so back() // reopen starts from the plain player (parity with the old per-open mount).
// returns there; if somehow there's nothing to go back to, replace to the tabs home so
// dismissing can never land on a blank screen.
const dismiss = () => { const dismiss = () => {
if (router.canGoBack()) router.back(); setMenuOpen(false);
else router.replace('/'); setQueueOpen(false);
usePlayerUiStore.getState().closePlayer();
}; };
const finishCloseMenu = () => setMenuOpen(false); const finishCloseMenu = () => setMenuOpen(false);
@@ -480,6 +485,39 @@ export default function NowPlayingScreen() {
} }
}); });
// Open animation (close normally animates via dismissSheet's spring first;
// the else branch covers direct closePlayer calls and keeps the resting
// offset pinned to the current window height across rotation). NOTE: this
// effect must stay BELOW every direct `translateY.value` write — the react
// compiler forbids mutations after an effect that depends on the value.
useEffect(() => {
if (playerOpen) {
translateY.value = withTiming(0, { duration: 240 });
} else {
translateY.value = withTiming(windowHeight, { duration: 200 });
}
}, [playerOpen, windowHeight, translateY]);
// Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO)
// — e.g. the library-detail back interceptor underneath.
useEffect(() => {
if (!playerOpen) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (menuOpen) {
closeMenu();
return true;
}
if (queueOpen) {
setQueueOpen(false);
return true;
}
dismissSheet();
return true;
});
return () => sub.remove();
});
const contentStyle = useAnimatedStyle(() => ({ const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
})); }));
@@ -493,10 +531,9 @@ export default function NowPlayingScreen() {
})); }));
return ( return (
<View style={styles.backdrop}> <View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
<GestureDetector gesture={pan}> <GestureDetector gesture={pan}>
<Animated.View <Animated.View
entering={SlideInDown.duration(240)}
style={[ style={[
styles.content, styles.content,
contentStyle, contentStyle,
@@ -520,7 +557,7 @@ export default function NowPlayingScreen() {
{!lyricsMode && ( {!lyricsMode && (
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerSide}> <View style={styles.headerSide}>
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}> <Pressable style={styles.headerBtn} android_ripple={ripple.icon(22)} onPress={() => dismissSheet()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} /> <Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@@ -535,7 +572,7 @@ export default function NowPlayingScreen() {
<View style={[styles.headerSide, styles.headerActions]}> <View style={[styles.headerSide, styles.headerActions]}>
{!isDesktopTarget && track ? ( {!isDesktopTarget && track ? (
<Pressable <Pressable
style={styles.headerBtn} style={styles.headerBtn} android_ripple={ripple.icon(22)}
onPress={() => void setLyricsVisible(!lyricsVisible)} onPress={() => void setLyricsVisible(!lyricsVisible)}
hitSlop={12} hitSlop={12}
accessibilityLabel={lyricsVisible ? 'Hide lyrics' : 'Show lyrics'} accessibilityLabel={lyricsVisible ? 'Hide lyrics' : 'Show lyrics'}
@@ -549,7 +586,7 @@ export default function NowPlayingScreen() {
</Pressable> </Pressable>
) : null} ) : null}
<Pressable <Pressable
style={styles.headerBtn} style={styles.headerBtn} android_ripple={ripple.icon(22)}
onPress={openMenu} onPress={openMenu}
hitSlop={12} hitSlop={12}
accessibilityLabel="More options" accessibilityLabel="More options"
@@ -563,6 +600,7 @@ export default function NowPlayingScreen() {
{lyricsMode && track ? ( {lyricsMode && track ? (
<LyricsView <LyricsView
track={track} track={track}
active={playerOpen}
isPlaying={isPlaying} isPlaying={isPlaying}
isLoading={isLoading} isLoading={isLoading}
isFavorite={isFavorite} isFavorite={isFavorite}
@@ -634,7 +672,7 @@ export default function NowPlayingScreen() {
</View> </View>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.inlineActionBtn} style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
onPress={() => void sendDesktopControl('toggle-favorite')} onPress={() => void sendDesktopControl('toggle-favorite')}
accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: activeTrack.isFavorite }} accessibilityState={{ selected: activeTrack.isFavorite }}
@@ -657,6 +695,7 @@ export default function NowPlayingScreen() {
<View style={[styles.transport, { marginTop: layout.controlsGap }]}> <View style={[styles.transport, { marginTop: layout.controlsGap }]}>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
android_ripple={ripple.icon(24)}
style={[ style={[
styles.transportSideBtn, styles.transportSideBtn,
desktopSnapshot?.shuffle === undefined && styles.controlDisabled, desktopSnapshot?.shuffle === undefined && styles.controlDisabled,
@@ -675,7 +714,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={() => void sendDesktopControl('previous')} onPress={() => void sendDesktopControl('previous')}
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Previous" accessibilityLabel="Previous"
> >
<Ionicons <Ionicons
@@ -687,7 +726,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={() => void sendDesktopControl(isPlaying ? 'pause' : 'play')} onPress={() => void sendDesktopControl(isPlaying ? 'pause' : 'play')}
hitSlop={12} hitSlop={12}
style={styles.playButton} style={styles.playButton} android_ripple={ripple.onAccent()}
accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'} accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
> >
<Ionicons <Ionicons
@@ -699,7 +738,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={() => void sendDesktopControl('next')} onPress={() => void sendDesktopControl('next')}
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
accessibilityLabel="Next" accessibilityLabel="Next"
> >
<Ionicons <Ionicons
@@ -710,6 +749,7 @@ export default function NowPlayingScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
android_ripple={ripple.icon(24)}
style={[ style={[
styles.transportSideBtn, styles.transportSideBtn,
desktopSnapshot?.repeat === undefined && styles.controlDisabled, desktopSnapshot?.repeat === undefined && styles.controlDisabled,
@@ -762,7 +802,7 @@ export default function NowPlayingScreen() {
<View style={styles.subActions}> <View style={styles.subActions}>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.subBtn} style={styles.subBtn} android_ripple={ripple.icon(20)}
onPress={() => void reconnectDesktop()} onPress={() => void reconnectDesktop()}
accessibilityLabel="Reconnect to desktop" accessibilityLabel="Reconnect to desktop"
> >
@@ -771,7 +811,7 @@ export default function NowPlayingScreen() {
{desktopQueue ? ( {desktopQueue ? (
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.subBtn} style={styles.subBtn} android_ripple={ripple.icon(20)}
onPress={() => setQueueOpen(true)} onPress={() => setQueueOpen(true)}
accessibilityLabel="Desktop queue" accessibilityLabel="Desktop queue"
> >
@@ -794,12 +834,16 @@ export default function NowPlayingScreen() {
: 'Pair with Astra Desktop to control it here.'} : 'Pair with Astra Desktop to control it here.'}
</Text> </Text>
<Pressable <Pressable
style={styles.emptyAction} style={styles.emptyAction} android_ripple={ripple.bounded}
onPress={() => onPress={() => {
desktopConnection if (desktopConnection) {
? void reconnectDesktop() void reconnectDesktop();
: router.push('/desktop-remote' as never) return;
} }
// Route change happens under the overlay; slide it away.
dismissSheet();
router.push('/desktop-remote' as never);
}}
> >
<Text variant="label" color={colors.accentTextStrong}> <Text variant="label" color={colors.accentTextStrong}>
{desktopConnection ? 'Reconnect' : 'Pair desktop'} {desktopConnection ? 'Reconnect' : 'Pair desktop'}
@@ -871,7 +915,7 @@ export default function NowPlayingScreen() {
showChrome={false} showChrome={false}
mode={scopeMode} mode={scopeMode}
edgeFade edgeFade
paused={queueOpen} paused={!playerOpen || queueOpen}
/> />
<Pressable <Pressable
onPress={() => onPress={() =>
@@ -880,7 +924,7 @@ export default function NowPlayingScreen() {
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum') .setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
} }
hitSlop={12} hitSlop={12}
style={styles.scopeSwap} style={styles.scopeSwap} android_ripple={ripple.icon(24)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Showing ${ accessibilityLabel={`Showing ${
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope' scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
@@ -916,7 +960,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={navigateToArtist} onPress={navigateToArtist}
hitSlop={6} hitSlop={6}
style={styles.artistButton} style={styles.artistButton} android_ripple={ripple.bounded}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={`View artist ${track.artist}`} accessibilityLabel={`View artist ${track.artist}`}
> >
@@ -928,7 +972,7 @@ export default function NowPlayingScreen() {
</View> </View>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.inlineActionBtn} style={styles.inlineActionBtn} android_ripple={ripple.icon(22)}
onPress={() => void toggleFavorite(track)} onPress={() => void toggleFavorite(track)}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: isFavorite }} accessibilityState={{ selected: isFavorite }}
@@ -942,6 +986,7 @@ export default function NowPlayingScreen() {
</View> </View>
<WaveformSeekBar <WaveformSeekBar
active={playerOpen}
height={layout.waveformHeight} height={layout.waveformHeight}
touchPadding={WAVEFORM_TOUCH_PADDING} touchPadding={WAVEFORM_TOUCH_PADDING}
trackPath={track.path} trackPath={track.path}
@@ -951,7 +996,7 @@ export default function NowPlayingScreen() {
<View style={[styles.transport, { marginTop: layout.controlsGap }]}> <View style={[styles.transport, { marginTop: layout.controlsGap }]}>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.transportSideBtn} style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
onPress={() => void toggleShuffle()} onPress={() => void toggleShuffle()}
accessibilityLabel="Shuffle" accessibilityLabel="Shuffle"
accessibilityState={{ selected: shuffle }} accessibilityState={{ selected: shuffle }}
@@ -965,7 +1010,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={skipToPrevious} onPress={skipToPrevious}
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
> >
<Ionicons <Ionicons
name="play-skip-back" name="play-skip-back"
@@ -973,7 +1018,7 @@ export default function NowPlayingScreen() {
color={colors.textPrimary} color={colors.textPrimary}
/> />
</Pressable> </Pressable>
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}> <Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton} android_ripple={ripple.onAccent()}>
<Ionicons <Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'} name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={PLAY_ICON_SIZE} size={PLAY_ICON_SIZE}
@@ -983,7 +1028,7 @@ export default function NowPlayingScreen() {
<Pressable <Pressable
onPress={skipToNext} onPress={skipToNext}
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
> >
<Ionicons <Ionicons
name="play-skip-forward" name="play-skip-forward"
@@ -993,7 +1038,7 @@ export default function NowPlayingScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.transportSideBtn} style={styles.transportSideBtn} android_ripple={ripple.icon(24)}
onPress={() => void cycleRepeat()} onPress={() => void cycleRepeat()}
accessibilityLabel="Repeat" accessibilityLabel="Repeat"
accessibilityState={{ selected: repeat !== 'none' }} accessibilityState={{ selected: repeat !== 'none' }}
@@ -1022,7 +1067,7 @@ export default function NowPlayingScreen() {
<View style={styles.subActions}> <View style={styles.subActions}>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.subBtn} style={styles.subBtn} android_ripple={ripple.icon(20)}
onPress={() => void setScopeStageVisible(!scopeStageVisible)} onPress={() => void setScopeStageVisible(!scopeStageVisible)}
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
accessibilityState={{ selected: scopeStageVisible }} accessibilityState={{ selected: scopeStageVisible }}
@@ -1035,7 +1080,7 @@ export default function NowPlayingScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
hitSlop={10} hitSlop={10}
style={styles.subBtn} style={styles.subBtn} android_ripple={ripple.icon(20)}
onPress={() => setQueueOpen(true)} onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue" accessibilityLabel="Queue"
> >
@@ -1077,7 +1122,8 @@ export default function NowPlayingScreen() {
{menuItems.map((item) => ( {menuItems.map((item) => (
<Pressable <Pressable
key={item.key} key={item.key}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]} android_ripple={ripple.bounded}
style={styles.menuItem}
onPress={item.onPress} onPress={item.onPress}
accessibilityRole="button" accessibilityRole="button"
> >
@@ -1111,10 +1157,6 @@ export default function NowPlayingScreen() {
} }
const useStyles = createThemedStyles((colors) => ({ const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'transparent',
},
content: { content: {
flex: 1, flex: 1,
backgroundColor: colors.bgPrimary, backgroundColor: colors.bgPrimary,
@@ -1196,9 +1238,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.md, gap: spacing.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
menuItemPressed: {
opacity: 0.6,
},
menuItemLabel: { menuItemLabel: {
flex: 1, flex: 1,
}, },
+15 -13
View File
@@ -43,6 +43,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion'; import { motion } from '@/theme/motion';
import { artworkThumbFromSource } from '@/library/artwork'; import { artworkThumbFromSource } from '@/library/artwork';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics'; import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
@@ -155,6 +156,7 @@ interface QueueTrayProps {
export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) { export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
const snapPoints = useMemo(() => ['58%', '100%'], []); const snapPoints = useMemo(() => ['58%', '100%'], []);
@@ -595,7 +597,7 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
</Text> </Text>
</View> </View>
{canEdit ? ( {canEdit ? (
<Pressable <Pressable android_ripple={ripple.bounded}
hitSlop={10} hitSlop={10}
onPress={() => (editMode ? exitEdit() : setEditMode(true))} onPress={() => (editMode ? exitEdit() : setEditMode(true))}
accessibilityRole="button" accessibilityRole="button"
@@ -654,7 +656,8 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
{editMode && selectedCount > 0 ? ( {editMode && selectedCount > 0 ? (
<View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}> <View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}>
<Pressable <Pressable
style={({ pressed }) => [styles.actionBtn, pressed && styles.actionBtnPressed]} android_ripple={ripple.bounded}
style={styles.actionBtn}
onPress={groupPlayNext} onPress={groupPlayNext}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Play ${selectedCount} selected songs next`} accessibilityLabel={`Play ${selectedCount} selected songs next`}
@@ -665,7 +668,8 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={({ pressed }) => [styles.actionBtn, pressed && styles.actionBtnPressed]} android_ripple={ripple.bounded}
style={styles.actionBtn}
onPress={groupRemove} onPress={groupRemove}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Remove ${selectedCount} selected songs from queue`} accessibilityLabel={`Remove ${selectedCount} selected songs from queue`}
@@ -751,6 +755,7 @@ const QueueRow = memo(function QueueRow({
}: QueueRowProps) { }: QueueRowProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const entryKey = entry.key; const entryKey = entry.key;
const title = trackTitle(entry.track); const title = trackTitle(entry.track);
const artist = trackArtist(entry.track); const artist = trackArtist(entry.track);
@@ -848,7 +853,7 @@ const QueueRow = memo(function QueueRow({
const rowContent = ( const rowContent = (
<Animated.View style={[styles.row, rowSurfaceStyle]}> <Animated.View style={[styles.row, rowSurfaceStyle]}>
{editMode ? ( {editMode ? (
<Pressable <Pressable android_ripple={ripple.bounded}
hitSlop={8} hitSlop={8}
onPress={onToggleSelect} onPress={onToggleSelect}
style={styles.checkbox} style={styles.checkbox}
@@ -891,7 +896,8 @@ const QueueRow = memo(function QueueRow({
<Animated.View style={[styles.rowOuter, rowMotionStyle]}> <Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<Pressable <Pressable
onPress={onJump} onPress={onJump}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.rowPressable}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Play ${title}`} accessibilityLabel={`Play ${title}`}
accessibilityHint="Opens this song in the queue" accessibilityHint="Opens this song in the queue"
@@ -907,7 +913,8 @@ const QueueRow = memo(function QueueRow({
<Animated.View style={[styles.rowOuter, rowMotionStyle]}> <Animated.View style={[styles.rowOuter, rowMotionStyle]}>
<Pressable <Pressable
onPress={onToggleSelect} onPress={onToggleSelect}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.rowPressable}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ selected }} accessibilityState={{ selected }}
accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${title}`} accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${title}`}
@@ -927,7 +934,8 @@ const QueueRow = memo(function QueueRow({
> >
<Pressable <Pressable
onPress={onJump} onPress={onJump}
style={({ pressed }) => [styles.rowPressable, pressed && styles.rowPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.rowPressable}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Play ${title}`} accessibilityLabel={`Play ${title}`}
accessibilityHint="Opens this song in the queue" accessibilityHint="Opens this song in the queue"
@@ -1023,9 +1031,6 @@ const useStyles = createThemedStyles((colors) => ({
rowPressable: { rowPressable: {
height: QUEUE_ROW_HEIGHT, height: QUEUE_ROW_HEIGHT,
}, },
rowPressed: {
opacity: 0.72,
},
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -1090,9 +1095,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.sm, gap: spacing.sm,
paddingVertical: spacing.md, paddingVertical: spacing.md,
}, },
actionBtnPressed: {
opacity: 0.7,
},
actionText: { actionText: {
color: colors.accent, color: colors.accent,
}, },
+5 -5
View File
@@ -17,6 +17,7 @@ import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteQueueItem } from '@/types/desktopRemote'; import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
@@ -28,6 +29,7 @@ interface RemoteQueueSheetProps {
export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const queue = useDesktopRemoteStore((s) => s.queue); const queue = useDesktopRemoteStore((s) => s.queue);
const snapPoints = useMemo(() => ['58%', '100%'], []); const snapPoints = useMemo(() => ['58%', '100%'], []);
const renderFlashListScrollComponent = useBottomSheetScrollableCreator(); const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
@@ -66,7 +68,8 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
const renderItem = useCallback( const renderItem = useCallback(
({ item }: ListRenderItemInfo<DesktopRemoteQueueItem>) => ( ({ item }: ListRenderItemInfo<DesktopRemoteQueueItem>) => (
<Pressable <Pressable
style={({ pressed }) => [styles.row, pressed && !item.isCurrent && styles.rowPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.row}
onPress={() => playItem(item)} onPress={() => playItem(item)}
disabled={item.isCurrent} disabled={item.isCurrent}
accessibilityRole="button" accessibilityRole="button"
@@ -95,7 +98,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
) : null} ) : null}
</Pressable> </Pressable>
), ),
[playItem, colors, styles] [playItem, colors, styles, ripple]
); );
return ( return (
@@ -159,9 +162,6 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
gap: spacing.md, gap: spacing.md,
}, },
rowPressed: {
opacity: 0.6,
},
rowText: { rowText: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
+9 -5
View File
@@ -28,6 +28,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { rgbaFromHex } from '@/theme/colorUtils'; import { rgbaFromHex } from '@/theme/colorUtils';
import { enqueueTop, playTracks } from '@/audio/playbackController'; import { enqueueTop, playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter'; import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -483,6 +484,7 @@ function ResultRow({
onQueueTrack: (track: DbTrack) => void; onQueueTrack: (track: DbTrack) => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const isTrack = result.kind === 'track'; const isTrack = result.kind === 'track';
const isShowMode = result.kind === 'show-all' || result.kind === 'show-top'; const isShowMode = result.kind === 'show-all' || result.kind === 'show-top';
@@ -494,7 +496,7 @@ function ResultRow({
}; };
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.resultRow, active && styles.resultRowActive, isShowMode && styles.showModeRow]} style={[styles.resultRow, active && styles.resultRowActive, isShowMode && styles.showModeRow]}
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
@@ -509,7 +511,7 @@ function ResultRow({
</Text> </Text>
</View> </View>
{isTrack ? ( {isTrack ? (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.queueButton} style={styles.queueButton}
onPress={queueTrack} onPress={queueTrack}
hitSlop={8} hitSlop={8}
@@ -535,6 +537,7 @@ function QuickSearchPanel({
onClose: () => void; onClose: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -1005,7 +1008,7 @@ function QuickSearchPanel({
style={styles.input} style={styles.input}
/> />
{query.length > 0 ? ( {query.length > 0 ? (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
onPress={() => updateQuery('')} onPress={() => updateQuery('')}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
@@ -1014,7 +1017,7 @@ function QuickSearchPanel({
<Ionicons name="close-circle" size={18} color={colors.textTertiary} /> <Ionicons name="close-circle" size={18} color={colors.textTertiary} />
</Pressable> </Pressable>
) : null} ) : null}
<Pressable onPress={close} hitSlop={8} accessibilityRole="button" accessibilityLabel="Close search"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={close} hitSlop={8} accessibilityRole="button" accessibilityLabel="Close search">
<Ionicons name="close" size={20} color={colors.textSecondary} /> <Ionicons name="close" size={20} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@@ -1066,6 +1069,7 @@ function QuickSearchPanel({
export function QuickSearchOverlay() { export function QuickSearchOverlay() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const isOpen = useSearchStore((s) => s.isQuickSearchOpen); const isOpen = useSearchStore((s) => s.isQuickSearchOpen);
const initialQuery = useSearchStore((s) => s.initialQuery); const initialQuery = useSearchStore((s) => s.initialQuery);
const openVersion = useSearchStore((s) => s.openVersion); const openVersion = useSearchStore((s) => s.openVersion);
@@ -1085,7 +1089,7 @@ export function QuickSearchOverlay() {
onRequestClose={close} onRequestClose={close}
> >
<View style={styles.modalRoot}> <View style={styles.modalRoot}>
<Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityRole="button" /> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={StyleSheet.absoluteFill} onPress={close} accessibilityRole="button" />
{isOpen ? ( {isOpen ? (
<QuickSearchPanel key={openVersion} initialQuery={initialQuery} onClose={close} /> <QuickSearchPanel key={openVersion} initialQuery={initialQuery} onClose={close} />
) : null} ) : null}
+3 -1
View File
@@ -4,6 +4,7 @@ import { Text } from '@/components/Text';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents'; import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
const SWATCH_SIZE = 36; const SWATCH_SIZE = 36;
@@ -15,6 +16,7 @@ interface AccentSwatchRowProps {
/** Circular accent swatches; the selected one gets a ring + checkmark. */ /** Circular accent swatches; the selected one gets a ring + checkmark. */
export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) { export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
@@ -22,7 +24,7 @@ export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
{ACCENT_IDS.map((id) => { {ACCENT_IDS.map((id) => {
const selected = id === value; const selected = id === value;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded}
key={id} key={id}
onPress={() => onChange(id)} onPress={() => onChange(id)}
accessibilityRole="radio" accessibilityRole="radio"
+12 -6
View File
@@ -16,6 +16,7 @@ import {
} from '@/components/settings/SettingsSectionScaffold'; } from '@/components/settings/SettingsSectionScaffold';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import type { ReplayGainMode } from '@/audio/normalization'; import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping'; import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { BaseThemeId, PreferredDark } from '@/theme/resolve'; import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
@@ -81,6 +82,7 @@ export function themeOptionTitle(id: BaseThemeId): string {
export function AppearanceSettingsPanel() { export function AppearanceSettingsPanel() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme); const baseTheme = useThemeStore((s) => s.baseTheme);
const preferredDark = useThemeStore((s) => s.preferredDark); const preferredDark = useThemeStore((s) => s.preferredDark);
@@ -102,7 +104,7 @@ export function AppearanceSettingsPanel() {
{options.map((option) => { {options.map((option) => {
const selected = option.id === baseTheme; const selected = option.id === baseTheme;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={option.id} key={option.id}
style={[styles.option, selected && styles.optionSelected]} style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setBaseTheme(option.id)} onPress={() => void setBaseTheme(option.id)}
@@ -159,6 +161,7 @@ function LibraryFolderSettingsRow({
onRemove: (folder: FolderWithCount) => void; onRemove: (folder: FolderWithCount) => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
return ( return (
<View style={styles.folderSettingsRow}> <View style={styles.folderSettingsRow}>
@@ -181,7 +184,7 @@ function LibraryFolderSettingsRow({
: 'Access lost. Remove and add again.'} : 'Access lost. Remove and add again.'}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={8} hitSlop={8}
disabled={disabled} disabled={disabled}
onPress={() => onRemove(folder)} onPress={() => onRemove(folder)}
@@ -197,6 +200,7 @@ function LibraryFolderSettingsRow({
function LibraryFoldersSettings() { function LibraryFoldersSettings() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const folders = useLibraryStore((s) => s.folders); const folders = useLibraryStore((s) => s.folders);
const isScanning = useLibraryStore((s) => s.isScanning); const isScanning = useLibraryStore((s) => s.isScanning);
@@ -230,7 +234,7 @@ function LibraryFoldersSettings() {
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`} : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
</Text> </Text>
</View> </View>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]} style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning} disabled={isScanning}
onPress={() => void addFolder()} onPress={() => void addFolder()}
@@ -245,7 +249,7 @@ function LibraryFoldersSettings() {
{folders.length > 0 ? ( {folders.length > 0 ? (
<View style={styles.folderSettingsActions}> <View style={styles.folderSettingsActions}>
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]} style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning} disabled={isScanning}
onPress={() => void rescan()} onPress={() => void rescan()}
@@ -291,6 +295,7 @@ function LibraryFoldersSettings() {
export function LibrarySettingsPanel() { export function LibrarySettingsPanel() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode); const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -314,7 +319,7 @@ export function LibrarySettingsPanel() {
{ARTIST_GROUPING_OPTIONS.map((option) => { {ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode; const selected = option.mode === groupingMode;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={option.mode} key={option.mode}
style={[styles.option, selected && styles.optionSelected]} style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)} onPress={() => void setArtistGroupingMode(option.mode)}
@@ -353,6 +358,7 @@ export function LibrarySettingsPanel() {
export function AudioSettingsPanel() { export function AudioSettingsPanel() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled); const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs); const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
@@ -400,7 +406,7 @@ export function AudioSettingsPanel() {
{REPLAYGAIN_MODES.map((m) => { {REPLAYGAIN_MODES.map((m) => {
const selected = m.mode === replayGainMode; const selected = m.mode === replayGainMode;
return ( return (
<Pressable <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
key={m.mode} key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]} style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)} onPress={() => void setReplayGainMode(m.mode)}
@@ -14,6 +14,7 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
export type SettingsIconName = keyof typeof Ionicons.glyphMap; export type SettingsIconName = keyof typeof Ionicons.glyphMap;
@@ -26,12 +27,18 @@ export function SettingsSectionScreen({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const router = useRouter(); const router = useRouter();
return ( return (
<Screen> <Screen>
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}> <Pressable
android_ripple={ripple.bounded}
style={styles.back}
onPress={() => router.back()}
hitSlop={8}
>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} /> <Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Settings Settings
@@ -98,8 +105,9 @@ export function SettingsNavRow({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
return ( return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.rowIcon}> <View style={styles.rowIcon}>
<Ionicons name={icon} size={20} color={colors.accent} /> <Ionicons name={icon} size={20} color={colors.accent} />
</View> </View>
+4 -4
View File
@@ -12,6 +12,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
export interface ActionSheetItem { export interface ActionSheetItem {
key: string; key: string;
@@ -39,6 +40,7 @@ export function ActionSheet({
}) { }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -60,7 +62,8 @@ export function ActionSheet({
{items.map((item) => ( {items.map((item) => (
<Pressable <Pressable
key={item.key} key={item.key}
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.item}
onPress={item.onPress} onPress={item.onPress}
accessibilityRole="button" accessibilityRole="button"
> >
@@ -122,9 +125,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
}, },
itemPressed: {
opacity: 0.6,
},
itemLabel: { itemLabel: {
flex: 1, flex: 1,
}, },
+4 -4
View File
@@ -16,6 +16,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) { export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const styles = useStyles(); const styles = useStyles();
@@ -95,12 +96,14 @@ export function AppSheetItem({
}: AppSheetItemProps) { }: AppSheetItemProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple();
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary; const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
return ( return (
<View style={styles.itemRow}> <View style={styles.itemRow}>
<Pressable <Pressable
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]} android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.item}
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
> >
@@ -156,9 +159,6 @@ const useStyles = createThemedStyles((colors) => ({
gap: spacing.md, gap: spacing.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
}, },
itemPressed: {
opacity: 0.6,
},
itemLabel: { itemLabel: {
flex: 1, flex: 1,
}, },
@@ -17,6 +17,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
@@ -41,6 +42,7 @@ export function PlaylistPickerSheet({
onAdded, onAdded,
}: PlaylistPickerSheetProps) { }: PlaylistPickerSheetProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [step, setStep] = useState<'pick' | 'create'>('pick'); const [step, setStep] = useState<'pick' | 'create'>('pick');
const [playlistName, setPlaylistName] = useState(''); const [playlistName, setPlaylistName] = useState('');
@@ -82,12 +84,12 @@ export function PlaylistPickerSheet({
selectionColor={colors.accent} selectionColor={colors.accent}
/> />
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={() => setStep('pick')}> <Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={() => setStep('pick')}>
<Text variant="label" color={colors.textSecondary}> <Text variant="label" color={colors.textSecondary}>
Back Back
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]} style={[styles.btn, styles.create, !trimmedPlaylistName && styles.createDisabled]}
disabled={!trimmedPlaylistName} disabled={!trimmedPlaylistName}
onPress={addToNewPlaylist} onPress={addToNewPlaylist}
+4 -2
View File
@@ -14,6 +14,7 @@ import {
spacing, spacing,
} from '@/theme'; } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
interface TextPromptModalProps { interface TextPromptModalProps {
visible: boolean; visible: boolean;
@@ -41,6 +42,7 @@ function TextPromptModalInner({
onClose, onClose,
}: TextPromptModalProps) { }: TextPromptModalProps) {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const trimmed = value.trim(); const trimmed = value.trim();
@@ -69,12 +71,12 @@ function TextPromptModalInner({
selectionColor={colors.accent} selectionColor={colors.accent}
/> />
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={styles.action} onPress={onClose} accessibilityRole="button"> <Pressable android_ripple={ripple.bounded} style={styles.action} onPress={onClose} accessibilityRole="button">
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Cancel Cancel
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.action, !trimmed && styles.actionDisabled]} style={[styles.action, !trimmed && styles.actionDisabled]}
disabled={!trimmed} disabled={!trimmed}
onPress={submit} onPress={submit}
+6 -4
View File
@@ -13,6 +13,7 @@ import { router, usePathname } from 'expo-router';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme'; import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { formatRelativeTime } from '@/lib/format'; import { formatRelativeTime } from '@/lib/format';
import { import {
buildSyncConflictResolutionPreview, buildSyncConflictResolutionPreview,
@@ -64,6 +65,7 @@ function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): s
export function SyncConflictPrompt() { export function SyncConflictPrompt() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const conflicts = useDesktopSyncStore((s) => s.conflicts); const conflicts = useDesktopSyncStore((s) => s.conflicts);
const status = useDesktopSyncStore((s) => s.status); const status = useDesktopSyncStore((s) => s.status);
@@ -158,7 +160,7 @@ export function SyncConflictPrompt() {
</Text> </Text>
<View style={styles.choiceList}> <View style={styles.choiceList}>
{options.map((resolution) => ( {options.map((resolution) => (
<Pressable <Pressable android_ripple={ripple.bounded}
key={resolution} key={resolution}
style={[ style={[
styles.choiceRow, styles.choiceRow,
@@ -183,7 +185,7 @@ export function SyncConflictPrompt() {
</Text> </Text>
</View> </View>
{count > 1 ? ( {count > 1 ? (
<Pressable onPress={review} style={styles.reviewLink}> <Pressable android_ripple={ripple.bounded} onPress={review} style={styles.reviewLink}>
<Text variant="caption" color={colors.accent}> <Text variant="caption" color={colors.accent}>
Review all {count} conflicts Review all {count} conflicts
</Text> </Text>
@@ -191,12 +193,12 @@ export function SyncConflictPrompt() {
) : null} ) : null}
</ScrollView> </ScrollView>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={styles.secondaryButton} onPress={dismissConflictPrompt}> <Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={dismissConflictPrompt}>
<Text variant="body" color={colors.textSecondary}> <Text variant="body" color={colors.textSecondary}>
Not now Not now
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable android_ripple={ripple.bounded}
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]} style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
disabled={!selectedResolution || busy} disabled={!selectedResolution || busy}
onPress={confirm} onPress={confirm}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
/**
* Now-playing overlay gate. The player is an always-mounted overlay above the
* navigator (not a route): `everOpened` latches the first mount so cold start
* pays nothing, `playerOpen` drives the UI-thread slide open/close. Session
* state only never persisted.
*/
interface PlayerUiStore {
playerOpen: boolean;
/** Mount latch: once true the overlay stays mounted (hidden) for instant reopen. */
everOpened: boolean;
openPlayer: () => void;
closePlayer: () => void;
/** Mount the overlay hidden (e.g. shortly after playback starts) so even the first open is instant. */
prewarm: () => void;
}
export const usePlayerUiStore = create<PlayerUiStore>((set) => ({
playerOpen: false,
everOpened: false,
openPlayer: () => set({ playerOpen: true, everOpened: true }),
closePlayer: () => set({ playerOpen: false }),
prewarm: () => set({ everOpened: true }),
}));
+8 -2
View File
@@ -5,8 +5,8 @@
* INVARIANT: solid-color tokens must stay 6-digit `#rrggbb` hex the Skia * INVARIANT: solid-color tokens must stay 6-digit `#rrggbb` hex the Skia
* visualizers (SpectrumCurve, OscilloscopeWave, EQGraph, GraphicResponseCurve) * visualizers (SpectrumCurve, OscilloscopeWave, EQGraph, GraphicResponseCurve)
* slice hex chars to build alpha variants. Only `glass*`, `*Glow`, * slice hex chars to build alpha variants. Only `glass*`, `*Glow`,
* `overlayFaint`, and `backdrop` may be `rgba()` strings (they are never fed * `overlayFaint`, `ripple`, and `backdrop` may be `rgba()` strings (they are
* through those helpers). * never fed through those helpers).
*/ */
export interface Palette { export interface Palette {
@@ -19,6 +19,8 @@ export interface Palette {
glassBg: string; glassBg: string;
glassBorder: string; glassBorder: string;
glassHighlight: string; glassHighlight: string;
/** Native android_ripple tint (glassHighlight family, transient so slightly stronger). */
ripple: string;
// Text ramp // Text ramp
textPrimary: string; textPrimary: string;
@@ -65,6 +67,7 @@ export const midnightBase: BasePalette = {
glassBg: 'rgba(124, 146, 196, 0.05)', glassBg: 'rgba(124, 146, 196, 0.05)',
glassBorder: 'rgba(124, 146, 196, 0.16)', glassBorder: 'rgba(124, 146, 196, 0.16)',
glassHighlight: 'rgba(140, 162, 208, 0.08)', glassHighlight: 'rgba(140, 162, 208, 0.08)',
ripple: 'rgba(140, 162, 208, 0.13)',
textPrimary: '#e2e8f4', textPrimary: '#e2e8f4',
textSecondary: '#8a98b8', textSecondary: '#8a98b8',
@@ -92,6 +95,7 @@ export const darkBase: BasePalette = {
glassBg: 'rgba(165, 175, 195, 0.05)', glassBg: 'rgba(165, 175, 195, 0.05)',
glassBorder: 'rgba(165, 175, 195, 0.16)', glassBorder: 'rgba(165, 175, 195, 0.16)',
glassHighlight: 'rgba(175, 185, 205, 0.08)', glassHighlight: 'rgba(175, 185, 205, 0.08)',
ripple: 'rgba(175, 185, 205, 0.13)',
textPrimary: '#e6e8ec', textPrimary: '#e6e8ec',
textSecondary: '#979da8', textSecondary: '#979da8',
@@ -120,6 +124,7 @@ export const amoledBase: BasePalette = {
glassBg: 'rgba(124, 146, 196, 0.06)', glassBg: 'rgba(124, 146, 196, 0.06)',
glassBorder: 'rgba(124, 146, 196, 0.20)', glassBorder: 'rgba(124, 146, 196, 0.20)',
glassHighlight: 'rgba(140, 162, 208, 0.10)', glassHighlight: 'rgba(140, 162, 208, 0.10)',
ripple: 'rgba(140, 162, 208, 0.14)',
textPrimary: '#e2e8f4', textPrimary: '#e2e8f4',
textSecondary: '#8a98b8', textSecondary: '#8a98b8',
@@ -148,6 +153,7 @@ export const lightBase: BasePalette = {
glassBg: 'rgba(52, 74, 130, 0.06)', glassBg: 'rgba(52, 74, 130, 0.06)',
glassBorder: 'rgba(52, 74, 130, 0.18)', glassBorder: 'rgba(52, 74, 130, 0.18)',
glassHighlight: 'rgba(52, 74, 130, 0.10)', glassHighlight: 'rgba(52, 74, 130, 0.10)',
ripple: 'rgba(52, 74, 130, 0.12)',
textPrimary: '#171d2e', textPrimary: '#171d2e',
textSecondary: '#4d5a78', textSecondary: '#4d5a78',
+2
View File
@@ -83,6 +83,7 @@ export function buildMaterialYouPalette(ramps: SystemPalette, isDark: boolean):
glassBg: rgbaFromHex(tone(ramps.accent2, 200), 0.06), glassBg: rgbaFromHex(tone(ramps.accent2, 200), 0.06),
glassBorder: rgbaFromHex(tone(ramps.accent2, 200), 0.18), glassBorder: rgbaFromHex(tone(ramps.accent2, 200), 0.18),
glassHighlight: rgbaFromHex(tone(ramps.accent2, 200), 0.09), glassHighlight: rgbaFromHex(tone(ramps.accent2, 200), 0.09),
ripple: rgbaFromHex(tone(ramps.accent2, 200), 0.13),
textPrimary: tone(ramps.neutral1, 50), textPrimary: tone(ramps.neutral1, 50),
textSecondary: tone(ramps.neutral2, 300), textSecondary: tone(ramps.neutral2, 300),
textTertiary: tone(ramps.neutral2, 500), textTertiary: tone(ramps.neutral2, 500),
@@ -107,6 +108,7 @@ export function buildMaterialYouPalette(ramps: SystemPalette, isDark: boolean):
glassBg: rgbaFromHex(tone(ramps.neutral2, 700), 0.06), glassBg: rgbaFromHex(tone(ramps.neutral2, 700), 0.06),
glassBorder: rgbaFromHex(tone(ramps.neutral2, 700), 0.16), glassBorder: rgbaFromHex(tone(ramps.neutral2, 700), 0.16),
glassHighlight: rgbaFromHex(tone(ramps.neutral2, 700), 0.09), glassHighlight: rgbaFromHex(tone(ramps.neutral2, 700), 0.09),
ripple: rgbaFromHex(tone(ramps.neutral2, 700), 0.12),
textPrimary: tone(ramps.neutral1, 900), textPrimary: tone(ramps.neutral1, 900),
textSecondary: tone(ramps.neutral2, 700), textSecondary: tone(ramps.neutral2, 700),
textTertiary: tone(ramps.neutral2, 500), textTertiary: tone(ramps.neutral2, 500),
+53
View File
@@ -0,0 +1,53 @@
// Native android_ripple configs — touch feedback drawn by the Android render
// thread the frame the finger lands, independent of JS-thread load. This is
// the app's ONE press-feedback system: JS `({ pressed })` dim styles were
// removed in its favor (persistent selected/active styles are state, not
// feedback, and stay).
import { useMemo } from 'react';
import type { PressableAndroidRippleConfig } from 'react-native';
import { useColors } from '@/theme/themed';
/**
* Fixed light overlay for accent-filled buttons the theme `ripple` token is
* invisible on a saturated accent fill. Theme-invariant on purpose (Material's
* on-color ripple), so it is not a palette token.
*/
const ON_ACCENT_COLOR = 'rgba(255, 255, 255, 0.24)';
/**
* `unstable_pressDelay` for Pressables inside SCROLLABLE content (rows, tiles,
* sheet items): a scroll flick claims the touch before the delay elapses, so
* scrolling never flashes ripples on everything it grazes mirroring Android's
* ViewConfiguration tap timeout in native lists. Quick real taps still ripple
* (RN fires press-in/out on release even under the delay). Fixed chrome
* (tab bar, transport, action bars) stays instant no delay there.
*/
export const SCROLL_PRESS_DELAY = 80;
export interface RippleSet {
/** Clips to the Pressable rect/borderRadius — rows, cards, pills, sheet items. */
bounded: PressableAndroidRippleConfig;
/** Bounded but drawn ABOVE children — tiles/cards whose artwork covers the Pressable. */
tile: PressableAndroidRippleConfig;
/** Circular unbounded ripple for transparent icon buttons; radius ≈ half the touch target. */
icon: (radius: number) => PressableAndroidRippleConfig;
/** Light overlay for accent-filled buttons (bounded unless a radius is given). */
onAccent: (radius?: number) => PressableAndroidRippleConfig;
}
export function useRipple(): RippleSet {
const colors = useColors();
return useMemo(
() => ({
bounded: { color: colors.ripple },
tile: { color: colors.ripple, foreground: true },
icon: (radius: number) => ({ color: colors.ripple, borderless: true, radius }),
onAccent: (radius?: number) =>
radius == null
? { color: ON_ACCENT_COLOR, foreground: true }
: { color: ON_ACCENT_COLOR, borderless: true, radius },
}),
[colors.ripple]
);
}