diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index 2baaf0b..9d0929f 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -38,6 +38,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { isWideWindow } from '@/theme/adaptive'; import { useEQStore } from '@/stores/eqStore'; import { useScopeActive } from '@/scope/scopeStore'; @@ -75,6 +76,7 @@ const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass' export default function EQScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const eq = useEQStore(); @@ -217,7 +219,7 @@ export default function EQScreen() { }; const presetRowEl = ( - setSheet('preset')} > @@ -285,7 +287,7 @@ export default function EQScreen() { onChange={eq.setPreamp} /> - @@ -321,10 +323,10 @@ export default function EQScreen() { - setSheet('save')} hitSlop={8}> + setSheet('save')} hitSlop={8}> - setSheet('overflow')} hitSlop={8}> + setSheet('overflow')} hitSlop={8}> diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index b5c0436..2d1808c 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -28,10 +28,12 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { rgbaFromHex } from '@/theme/colorUtils'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; +import { usePlayerUiStore } from '@/stores/playerUiStore'; import { useSearchStore } from '@/stores/searchStore'; import { playTracks, @@ -93,6 +95,7 @@ function SectionHeader({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); return ( @@ -106,7 +109,7 @@ function SectionHeader({ ) : null} {onActionPress && actionLabel ? ( - + {actionLabel} @@ -160,6 +163,7 @@ function NowPlayingCard({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const isPlaying = playbackState === 'playing'; const isLoading = playbackState === 'loading'; const scopeActive = useScopeActive(); @@ -194,7 +198,7 @@ function NowPlayingCard({ {scopeActive && cardSize.width > 0 ? ( ) : null} - + {track.artworkData ? ( ) : ( @@ -213,20 +217,20 @@ function NowPlayingCard({ - void skipToPrevious()}> + void skipToPrevious()}> - void togglePlay()} style={styles.playCircle}> + void togglePlay()} style={styles.playCircle} android_ripple={ripple.onAccent()} unstable_pressDelay={SCROLL_PRESS_DELAY}> - void skipToNext()}> + void skipToNext()}> - + @@ -243,8 +247,9 @@ function RecentlyAddedAlbum({ onPress: () => void; }) { const styles = useStyles(); + const ripple = useRipple(); return ( - + {album.album} @@ -273,11 +278,12 @@ function RandomAlbumCard({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const disabled = tracks.length === 0; return ( - + @@ -294,7 +300,7 @@ function RandomAlbumCard({ @@ -356,6 +365,7 @@ function EmptyHomeCard({ ) : null} router.push('/now-playing')} + onOpen={() => usePlayerUiStore.getState().openPlayer()} /> ) : null} @@ -502,7 +512,7 @@ export default function HomeScreen() { router.push('/now-playing')} + onOpen={() => usePlayerUiStore.getState().openPlayer()} /> ) : randomAlbum ? ( void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( @@ -303,7 +305,7 @@ function SectionHeader({ {onPress ? ( - + See all @@ -322,6 +324,7 @@ function AlbumRail({ onAlbumPress: (album: ArtistAlbum) => void; }) { const styles = useStyles(); + const ripple = useRipple(); return ( {albums.map((album) => ( - onAlbumPress(album)} diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 3f4f2e6..86e0444 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -12,12 +12,14 @@ import { Text } from '@/components/Text'; import { AlbumGridItem } from '@/components/library/AlbumGridItem'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { buildArtistDetail } from '@/library/artistDetail'; export default function ArtistAlbumsScreen() { const colors = useColors(); + const ripple = useRipple(); const router = useRouter(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const allTracks = useLibraryStore((s) => s.tracks); @@ -30,7 +32,7 @@ export default function ArtistAlbumsScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> {name} diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index 6c68748..ba2f702 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; @@ -23,6 +24,7 @@ import type { DbTrack } from '@/types/library'; export default function ArtistAppearancesScreen() { const colors = useColors(); + const ripple = useRipple(); const router = useRouter(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const allTracks = useLibraryStore((s) => s.tracks); @@ -43,7 +45,7 @@ export default function ArtistAppearancesScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> {name} diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index 79b132d..7d987b6 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; @@ -23,6 +24,7 @@ import type { DbTrack } from '@/types/library'; export default function ArtistSongsScreen() { const colors = useColors(); + const ripple = useRipple(); const router = useRouter(); const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); const allTracks = useLibraryStore((s) => s.tracks); @@ -43,7 +45,7 @@ export default function ArtistSongsScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> {name} diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 1a6d0fe..94dc131 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -39,6 +39,7 @@ import { } from '@/components/search/PullSearchGesture'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSearchStore } from '@/stores/searchStore'; @@ -77,6 +78,7 @@ const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count']; export default function LibraryScreen() { const colors = useColors(); + const ripple = useRipple(); const router = useRouter(); const viewMode = useLibraryStore((s) => s.viewMode); const setViewMode = useLibraryStore((s) => s.setViewMode); @@ -244,7 +246,7 @@ export default function LibraryScreen() { Library {!isEmpty ? ( - openQuickSearch()} accessibilityRole="button" @@ -281,14 +283,14 @@ export default function LibraryScreen() { {selectedIds.size} selected - + Cancel ) : sortable ? ( - setSortSheetOpen(true)} accessibilityRole="button" diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 9bb1bf6..a3c2be8 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -27,6 +27,7 @@ import { TextPromptModal } from '@/components/sheets/TextPromptModal'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; import { playTracks, shuffleTracks } from '@/audio/playbackController'; @@ -54,9 +55,10 @@ function basename(path: string): string { function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( - + {entry.fallback_title ?? basename(entry.track_path)} @@ -74,6 +76,7 @@ type Prompt = { kind: 'rename'; playlist: Playlist } | null; export default function PlaylistScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); @@ -283,7 +286,7 @@ export default function PlaylistScreen() { heroMeta={{meta}} heroExtra={ isDynamic && playlistId != null ? ( - router.push({ diff --git a/src/app/(tabs)/library/playlist/edit-dynamic.tsx b/src/app/(tabs)/library/playlist/edit-dynamic.tsx index fdd30b4..98f512f 100644 --- a/src/app/(tabs)/library/playlist/edit-dynamic.tsx +++ b/src/app/(tabs)/library/playlist/edit-dynamic.tsx @@ -27,6 +27,7 @@ import { } from '@/components/sheets/AppSheet'; import { fonts, fontSize, radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { usePlaylistStore } from '@/stores/playlistStore'; import { DYNAMIC_PLAYLIST_PRESETS, @@ -327,15 +328,16 @@ function DraftActions({ onApply: () => void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( - + Cancel - void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const draft = target.draft; const error = validateCondition(draft); @@ -536,7 +539,7 @@ function ConditionEditorSheet({ return ( - + FIELD @@ -568,7 +571,7 @@ function ConditionEditorSheet({ {target.mode === 'edit' ? ( - + Remove @@ -713,11 +716,12 @@ function FilterCard({ onRemove: () => void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const option = fieldOptionForKey(getConditionFieldKey(condition)); return ( - + @@ -729,7 +733,7 @@ function FilterCard({ {fieldGroupLabel(condition)} - (); @@ -1036,7 +1041,7 @@ export default function DynamicPlaylistEditorScreen() { > - router.back()} hitSlop={8} style={styles.headerButton} @@ -1080,7 +1085,7 @@ export default function DynamicPlaylistEditorScreen() { contentContainerStyle={styles.presetRow} > {DYNAMIC_PLAYLIST_PRESETS.map((preset) => ( - applyPreset(preset.rules)} @@ -1100,7 +1105,7 @@ export default function DynamicPlaylistEditorScreen() { FILTERS - openFieldPicker('new')} accessibilityRole="button"> + openFieldPicker('new')} accessibilityRole="button"> Add filter @@ -1133,7 +1138,7 @@ export default function DynamicPlaylistEditorScreen() { ORDER - + @@ -1161,7 +1166,7 @@ export default function DynamicPlaylistEditorScreen() { {status.label} - setSheet({ kind: 'preview' })} @@ -1172,7 +1177,7 @@ export default function DynamicPlaylistEditorScreen() { Preview - { - if (isNotificationClickPath(path)) return getNotificationClickRedirectPath(); + if (isNotificationClickPath(path)) return resolveNotificationClick(); const eqShareRedirect = getEQPresetShareRedirectPath(path); if (eqShareRedirect) return eqShareRedirect; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 154aa72..ebcd798 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -17,6 +17,7 @@ import { JetBrainsMono_500Medium, } from '@expo-google-fonts/jetbrains-mono'; import { usePlaybackSync } from '@/audio/usePlaybackSync'; +import { NowPlayingHost } from '@/components/player/NowPlayingHost'; import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay'; import { useScopeLifecycle } from '@/scope/useScopeLifecycle'; import { useLibraryStore } from '@/stores/libraryStore'; @@ -45,10 +46,12 @@ import { useOnboardingStore } from '@/stores/onboardingStore'; import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow'; import { useTheme } from '@/theme/themed'; -// Anchor the root stack at the tabs so a deep link straight to a top-level route (the -// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]` -// instead of just `[route]`. Without this, dismissing the now-playing modal pops to an empty -// stack → blank screen. Only affects deep-link/launch ordering; normal nav is unchanged. +// Anchor the root stack at the tabs so a deep link straight to a top-level route +// (the widget's `recently-played`, the notification-click redirect) builds +// `[(tabs), route]` instead of just `[route]` — backing out of a deep-linked +// 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 = { initialRouteName: '(tabs)', }; @@ -357,18 +360,12 @@ export default function RootLayout() { }} > - {onboardingComplete ? ( <> + {/* Always-mounted player overlay (store-gated); open/close is a pure + UI-thread slide with zero mount cost after the first mount. */} + diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx index d87265f..07a0821 100644 --- a/src/app/desktop-remote.tsx +++ b/src/app/desktop-remote.tsx @@ -27,8 +27,10 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; +import { usePlayerUiStore } from '@/stores/playerUiStore'; import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote'; // Layout mirrors now-playing's adaptive pattern (minus the scope stage): a @@ -189,9 +191,10 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: { disabled: boolean; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( - onPair(desktop)} disabled={disabled} @@ -219,6 +222,7 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: { export default function DesktopRemoteScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const { pair } = useLocalSearchParams<{ pair?: string }>(); @@ -390,7 +394,7 @@ export default function DesktopRemoteScreen() { maxLength={6} textContentType="oneTimeCode" /> - - router.push('/desktop-remote/scan' as never)}> + router.push('/desktop-remote/scan' as never)}> Scan QR @@ -433,7 +437,7 @@ export default function DesktopRemoteScreen() { autoCorrect={false} keyboardType="url" /> - void pairFromInput(pairingLink)} @@ -470,7 +474,7 @@ export default function DesktopRemoteScreen() { autoCapitalize="none" autoCorrect={false} /> - - router.back()} hitSlop={12}> + router.back()} hitSlop={12}> @@ -534,7 +538,7 @@ export default function DesktopRemoteScreen() { {remoteSource} - void reconnect()} hitSlop={12} @@ -582,20 +586,26 @@ export default function DesktopRemoteScreen() { - 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('/'); + }} > Open Now Playing - void reconnect()}> + void reconnect()}> Reconnect - + Forget desktop @@ -630,7 +640,7 @@ export default function DesktopRemoteScreen() { ) : ( <> - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Settings diff --git a/src/app/desktop-remote/scan.tsx b/src/app/desktop-remote/scan.tsx index 383b1a6..287a01d 100644 --- a/src/app/desktop-remote/scan.tsx +++ b/src/app/desktop-remote/scan.tsx @@ -19,10 +19,12 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; export default function DesktopRemoteScanScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput); @@ -42,7 +44,7 @@ export default function DesktopRemoteScanScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Desktop Remote @@ -62,7 +64,7 @@ export default function DesktopRemoteScanScreen() { Camera access is needed to scan the desktop pairing QR. - void requestPermission()}> + void requestPermission()}> Allow camera diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx index b504d31..a3276e3 100644 --- a/src/app/desktop-sync.tsx +++ b/src/app/desktop-sync.tsx @@ -20,6 +20,7 @@ import { Text } from '@/components/Text'; import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { formatRelativeTime } from '@/lib/format'; import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; @@ -67,6 +68,7 @@ function ConflictCard({ onResolve: (resolution: DesktopSyncConflictResolution) => void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const [selectedResolution, setSelectedResolution] = useState(null); const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote); @@ -85,7 +87,7 @@ function ConflictCard({ {options.map((resolution) => ( - - selectedResolution ? onResolve(selectedResolution) : undefined} @@ -130,6 +132,7 @@ function ConflictCard({ export default function DesktopSyncScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const status = useDesktopSyncStore((s) => s.status); @@ -172,7 +175,7 @@ export default function DesktopSyncScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Settings @@ -201,7 +204,7 @@ export default function DesktopSyncScreen() { Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop once and both features work. - router.push('/desktop-remote' as never)} > @@ -225,7 +228,7 @@ export default function DesktopSyncScreen() { : 'Not synced yet'} - void syncNow()} diff --git a/src/app/eq/import.tsx b/src/app/eq/import.tsx index 4ce6e0a..e613d53 100644 --- a/src/app/eq/import.tsx +++ b/src/app/eq/import.tsx @@ -10,10 +10,12 @@ import { genEqId } from '@/audio/eqPresets'; import { useEQStore } from '@/stores/eqStore'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import type { EQPreset } from '@/types/audio'; export default function EQPresetImportScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const importPreset = useEQStore((state) => state.importPreset); @@ -34,7 +36,7 @@ export default function EQPresetImportScreen() { return ( - + Equalizer @@ -44,7 +46,7 @@ export default function EQPresetImportScreen() { This link does not contain a valid Astra EQ preset. - + Go to Equalizer diff --git a/src/app/eq/scan.tsx b/src/app/eq/scan.tsx index 88bdd2c..75397ce 100644 --- a/src/app/eq/scan.tsx +++ b/src/app/eq/scan.tsx @@ -22,10 +22,12 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import type { EQPreset } from '@/types/audio'; export default function EQPresetScanScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const importPreset = useEQStore((state) => state.importPreset); @@ -49,7 +51,7 @@ export default function EQPresetScanScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Equalizer @@ -67,7 +69,7 @@ export default function EQPresetScanScreen() { Camera access is needed to scan EQ preset QR codes. - void requestPermission()}> + void requestPermission()}> Allow camera @@ -90,7 +92,7 @@ export default function EQPresetScanScreen() { {error} - setError(null)}> + setError(null)}> Scan again diff --git a/src/app/lastfm/edit.tsx b/src/app/lastfm/edit.tsx index 369123a..761a283 100644 --- a/src/app/lastfm/edit.tsx +++ b/src/app/lastfm/edit.tsx @@ -19,6 +19,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import type { LastFmScrobbleProtocol } from '@/types/lastFm'; @@ -103,6 +104,7 @@ function Field({ export default function LastFmEditScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); @@ -200,7 +202,7 @@ export default function LastFmEditScreen() { behavior={Platform.OS === 'ios' ? 'padding' : undefined} > - + {backLabel} @@ -219,7 +221,7 @@ export default function LastFmEditScreen() { {PROTOCOL_OPTIONS.map((option) => ( - chooseProtocol(option.protocol)} @@ -293,7 +295,7 @@ export default function LastFmEditScreen() { ) : null} - void onSave()} disabled={!canSubmit || busy} @@ -308,7 +310,7 @@ export default function LastFmEditScreen() { {editing ? ( - + Remove destination diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx index e54cb4e..32054dd 100644 --- a/src/app/lastfm/index.tsx +++ b/src/app/lastfm/index.tsx @@ -16,6 +16,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { requestLastFmFlush } from '@/services/lastfm'; import type { LastFmProfileStatus } from '@/types/lastFm'; @@ -45,6 +46,7 @@ function customStatusLine(profile: LastFmProfileStatus): { text: string; tone: ' export default function LastFmScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const status = useLastFmSettingsStore((s) => s.status); @@ -125,7 +127,7 @@ export default function LastFmScreen() { {profile.username} ) : null} - confirmDisconnect(profile)} hitSlop={8} accessibilityLabel="Disconnect Last.fm" @@ -134,7 +136,7 @@ export default function LastFmScreen() { ) : ( - connectOfficial(profile)} accessibilityRole="button" @@ -152,7 +154,7 @@ export default function LastFmScreen() { const renderCustom = (profile: LastFmProfileStatus) => { const line = customStatusLine(profile); return ( - router.push({ pathname: '/lastfm/edit', params: { id: profile.id } })} @@ -195,13 +197,13 @@ export default function LastFmScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Settings - router.push('/lastfm/edit')} hitSlop={8} accessibilityLabel="Add destination" @@ -249,7 +251,7 @@ export default function LastFmScreen() { ) : null} {status && status.pendingScrobbles > 0 ? ( - requestLastFmFlush()}> + requestLastFmFlush()}> Retry {status.pendingScrobbles} queued now @@ -271,7 +273,7 @@ export default function LastFmScreen() { )} - router.push('/lastfm/edit')}> + router.push('/lastfm/edit')}> Add destination diff --git a/src/app/notification.click.tsx b/src/app/notification.click.tsx index e6e729d..1ba35de 100644 --- a/src/app/notification.click.tsx +++ b/src/app/notification.click.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { View } from 'react-native'; import { useRouter } from 'expo-router'; -import { getNotificationClickRedirectPath } from '@/audio/notificationIntent'; +import { resolveNotificationClick } from '@/audio/notificationIntent'; import { createThemedStyles } from '@/theme/themed'; export default function NotificationClickRoute() { @@ -11,7 +11,7 @@ export default function NotificationClickRoute() { useEffect(() => { let cancelled = false; - getNotificationClickRedirectPath() + resolveNotificationClick() .then((href) => { if (!cancelled) router.replace(href); }) diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx index 5ae59bf..9dd0a1e 100644 --- a/src/app/recently-played.tsx +++ b/src/app/recently-played.tsx @@ -13,6 +13,7 @@ import { TrackRow } from '@/components/library/TrackRow'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import { playTracks } from '@/audio/playbackController'; @@ -37,6 +38,7 @@ function EmptyList() { export default function RecentlyPlayedScreen() { const colors = useColors(); + const ripple = useRipple(); const router = useRouter(); const tracks = useLibraryStore((s) => s.recentlyPlayedTracks); const currentPath = usePlayerStore((s) => s.currentTrack?.path); @@ -49,7 +51,7 @@ export default function RecentlyPlayedScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Home diff --git a/src/app/sources/edit.tsx b/src/app/sources/edit.tsx index a7af8db..dedd6a7 100644 --- a/src/app/sources/edit.tsx +++ b/src/app/sources/edit.tsx @@ -18,6 +18,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import type { RemoteSourceType } from '@/types/remote'; @@ -84,6 +85,7 @@ function Field({ export default function SourceEditScreen() { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); @@ -187,7 +189,7 @@ export default function SourceEditScreen() { behavior={Platform.OS === 'ios' ? 'padding' : undefined} > - + {backLabel} @@ -206,7 +208,7 @@ export default function SourceEditScreen() { {TYPE_OPTIONS.map((option) => ( - chooseType(option.type)} @@ -274,7 +276,7 @@ export default function SourceEditScreen() { ) : null} - void onTest()} disabled={!!busy} @@ -287,7 +289,7 @@ export default function SourceEditScreen() { )} - s.sources); @@ -103,7 +105,7 @@ export default function SourcesScreen() { return ( - router.back()} hitSlop={8}> + router.back()} hitSlop={8}> Settings @@ -111,11 +113,11 @@ export default function SourcesScreen() { {sources.length > 0 ? ( - void syncAll()} hitSlop={8} accessibilityLabel="Sync all"> + void syncAll()} hitSlop={8} accessibilityLabel="Sync all"> ) : null} - router.push('/sources/edit')} hitSlop={8} 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 self-hosted library. - router.push('/sources/edit')}> + router.push('/sources/edit')}> Add server @@ -148,7 +150,7 @@ export default function SourcesScreen() { sources.map((source) => { const status = statusLine(source, progressById[source.id] ?? null); return ( - setActionFor(source)} diff --git a/src/audio/notificationIntent.ts b/src/audio/notificationIntent.ts index c911844..6503e9e 100644 --- a/src/audio/notificationIntent.ts +++ b/src/audio/notificationIntent.ts @@ -1,5 +1,6 @@ import TrackPlayer from 'react-native-track-player'; import { usePlayerStore } from '@/stores/playerStore'; +import { usePlayerUiStore } from '@/stores/playerUiStore'; const NOTIFICATION_CLICK_TARGETS = new Set([ 'trackplayer://notification.click', @@ -9,8 +10,6 @@ const NOTIFICATION_CLICK_TARGETS = new Set([ 'notification.click', ]); -export type NotificationClickRedirectPath = '/' | '/now-playing'; - export function isNotificationClickPath(path: string): boolean { const cleanPath = path.trim(); const pathWithoutSuffix = cleanPath.split(/[?#]/, 1)[0]; @@ -31,8 +30,14 @@ export function isNotificationClickPath(path: string): boolean { } } -export async function getNotificationClickRedirectPath(): Promise { - 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 { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 08d14c1..d684409 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -7,7 +7,7 @@ import { } from 'react-native'; import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; -import { usePathname, useRouter } from 'expo-router'; +import { usePlayerUiStore } from '@/stores/playerUiStore'; import { Text } from './Text'; import { AstraLogo } from './AstraLogo'; import { SpectrumCurve } from './SpectrumCurve'; @@ -16,6 +16,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { usePlayerStore } from '@/stores/playerStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -72,8 +73,8 @@ function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) { export function MiniPlayer({ visible = true }: MiniPlayerProps) { const styles = useStyles(); const colors = useColors(); - const router = useRouter(); - const pathname = usePathname(); + const ripple = useRipple(); + const playerOpen = usePlayerUiStore((s) => s.playerOpen); const selectedTarget = usePlaybackTargetStore((s) => s.target); const track = usePlayerStore((s) => s.currentTrack); const playbackState = usePlayerStore((s) => s.playbackState); @@ -107,9 +108,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { const isDesktop = presentation.target === 'desktop'; const isPlaying = presentation.playbackState === 'playing'; const isLoading = presentation.playbackState === 'loading'; - // The pill sits underneath the now-playing transparentModal; don't burn a - // second live-scope frame loop while it's fully occluded. - const liveScopeActive = visible && scopeActive && !isDesktop && pathname !== '/now-playing'; + // The pill sits underneath the now-playing overlay; don't burn a second + // live-scope frame loop while it's fully occluded. + const liveScopeActive = visible && scopeActive && !isDesktop && !playerOpen; const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width); const onTogglePlay = () => { @@ -135,8 +136,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { <> router.push('/now-playing')} + onPress={() => usePlayerUiStore.getState().openPlayer()} onLayout={onLayout} > {liveScopeActive && pillWidth > 0 && ( @@ -183,6 +185,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { {isDesktop ? ( setTargetPickerOpen(true)} style={styles.control} accessibilityLabel="Choose output device" @@ -190,14 +193,14 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { ) : null} - + - + diff --git a/src/components/PlaybackTargetPicker.tsx b/src/components/PlaybackTargetPicker.tsx index 0862def..5432502 100644 --- a/src/components/PlaybackTargetPicker.tsx +++ b/src/components/PlaybackTargetPicker.tsx @@ -10,6 +10,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Text } from './Text'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -118,9 +119,11 @@ function TargetRow({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); return ( [styles.row, pressed && styles.rowPressed]} + android_ripple={ripple.bounded} + style={styles.row} onPress={onPress} accessibilityRole="button" accessibilityState={{ selected }} @@ -176,9 +179,6 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', gap: spacing.md, }, - rowPressed: { - opacity: 0.65, - }, iconWrap: { width: 40, height: 40, diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx index eb50bc5..4a0f739 100644 --- a/src/components/SegmentedControl.tsx +++ b/src/components/SegmentedControl.tsx @@ -16,6 +16,7 @@ import { radius, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; const THUMB_INSET = 3; @@ -89,6 +90,7 @@ function SegmentButton({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); // 0 = inactive, 1 = active; drives the label colour cross-fade. const progress = useSharedValue(focused ? 1 : 0); @@ -106,7 +108,8 @@ function SegmentButton({ return ( [styles.segment, pressed && styles.segmentPressed]} + android_ripple={ripple.bounded} + style={styles.segment} onPress={onPress} accessibilityRole="tab" accessibilityState={{ selected: focused }} @@ -143,9 +146,6 @@ const useStyles = createThemedStyles((colors) => ({ justifyContent: 'center', paddingVertical: 7, }, - segmentPressed: { - opacity: 0.72, - }, label: { fontSize: 12, fontFamily: fonts.sans.medium, diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 9914120..6d64d5a 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -20,6 +20,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -161,6 +162,7 @@ interface TabButtonProps { function TabButton({ meta, focused, onPress }: TabButtonProps) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); // 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom. const progress = useSharedValue(focused ? 1 : 0); // 0 = at rest, 1 = finger down. @@ -184,6 +186,7 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) { return ( { diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index b20dd07..824c7ff 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -36,6 +36,11 @@ interface WaveformSeekBarProps { touchPadding?: number; /** Track file URI used to load/cache the offline waveform peaks. */ 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)); @@ -54,12 +59,13 @@ export function WaveformSeekBar({ height = CANVAS_HEIGHT, touchPadding = spacing.md, trackPath, + active = true, }: WaveformSeekBarProps) { const styles = useStyles(); const colors = useColors(); - const currentTime = usePlayerStore((s) => s.currentTime); + const currentTime = usePlayerStore((s) => (active ? s.currentTime : 0)); 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(null); const [barWidth, setBarWidth] = useState(0); const pendingSeek = usePlayerStore((s) => s.pendingSeek); diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx index b49c2ff..53ecc73 100644 --- a/src/components/eq/BandDetailPanel.tsx +++ b/src/components/eq/BandDetailPanel.tsx @@ -11,6 +11,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import type { EQBand } from '@/types/audio'; import { EQ_MAX_FREQUENCY, @@ -43,6 +44,7 @@ export type EQEditableValue = 'frequency' | 'gain' | 'Q'; /** "Band N" + type dropdown + On toggle + audible parameter sliders. */ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); if (!band) { return ( @@ -61,7 +63,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit Band {bandNumber} - + {BAND_TYPE_LABEL[band.type]} diff --git a/src/components/eq/BandStrip.tsx b/src/components/eq/BandStrip.tsx index f634cf9..2d95025 100644 --- a/src/components/eq/BandStrip.tsx +++ b/src/components/eq/BandStrip.tsx @@ -10,6 +10,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import type { EQBand } from '@/types/audio'; import { formatFreq, @@ -28,6 +29,7 @@ interface BandStripProps { /** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( { const isActive = band.id === activeBandId; return ( - onSelect(band.id)} style={[styles.cell, isActive && styles.cellActive]} @@ -56,7 +58,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band ); })} {canAdd ? ( - + ) : null} diff --git a/src/components/eq/EQPresetNameSheet.tsx b/src/components/eq/EQPresetNameSheet.tsx index 27c6ea3..1e06577 100644 --- a/src/components/eq/EQPresetNameSheet.tsx +++ b/src/components/eq/EQPresetNameSheet.tsx @@ -12,6 +12,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { EqSheet } from './EqSheet'; interface EQPresetNameSheetProps { @@ -28,6 +29,7 @@ export function EQPresetNameSheet({ onClose, }: EQPresetNameSheetProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const [name, setName] = useState(defaultName); const trimmed = name.trim(); @@ -56,12 +58,12 @@ export function EQPresetNameSheet({ onSubmitEditing={submit} /> - + Cancel - + {actionLabel} diff --git a/src/components/eq/EQPresetPreviewSheet.tsx b/src/components/eq/EQPresetPreviewSheet.tsx index dfe4f30..0ef3fd5 100644 --- a/src/components/eq/EQPresetPreviewSheet.tsx +++ b/src/components/eq/EQPresetPreviewSheet.tsx @@ -9,6 +9,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import type { EQPreset } from '@/types/audio'; import { EqSheet } from './EqSheet'; import { formatGain } from './format'; @@ -29,6 +30,7 @@ export function EQPresetPreviewSheet({ onClose, }: EQPresetPreviewSheetProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const enabledBands = preset.bands.filter((band) => band.enabled).length; const modeLabel = preset.mode === 'graphic' ? 'Graphic' : 'Parametric'; @@ -68,12 +70,12 @@ export function EQPresetPreviewSheet({ - + Cancel - { onConfirm(); diff --git a/src/components/eq/EQPresetQrSheet.tsx b/src/components/eq/EQPresetQrSheet.tsx index 5b7e1d9..9513588 100644 --- a/src/components/eq/EQPresetQrSheet.tsx +++ b/src/components/eq/EQPresetQrSheet.tsx @@ -10,6 +10,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { EqSheet } from './EqSheet'; interface EQPresetQrSheetProps { @@ -20,6 +21,7 @@ interface EQPresetQrSheetProps { export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); return ( @@ -33,7 +35,7 @@ export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetP {presetName} - + Done diff --git a/src/components/eq/EQSlider.tsx b/src/components/eq/EQSlider.tsx index 6e11210..332f208 100644 --- a/src/components/eq/EQSlider.tsx +++ b/src/components/eq/EQSlider.tsx @@ -12,6 +12,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; const THUMB = 16; @@ -43,6 +44,7 @@ export function EQSlider({ onValuePress, }: EQSliderProps) { const styles = useStyles(); + const ripple = useRipple(); const [width, setWidth] = useState(0); const [active, setActive] = useState(false); const widthRef = useRef(0); @@ -119,6 +121,9 @@ export function EQSlider({ {onValuePress && !disabled ? ( [styles.valueButton, pressed && styles.valueButtonPressed]} onPress={onValuePress} accessibilityRole="button" diff --git a/src/components/eq/EQValueEditSheet.tsx b/src/components/eq/EQValueEditSheet.tsx index 65b262f..ea8e5f0 100644 --- a/src/components/eq/EQValueEditSheet.tsx +++ b/src/components/eq/EQValueEditSheet.tsx @@ -13,6 +13,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { EqSheet } from './EqSheet'; interface EQValueEditSheetProps { @@ -40,6 +41,7 @@ export function EQValueEditSheet({ onClose, }: EQValueEditSheetProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const [value, setValue] = useState(initialValue); const trimmed = value.trim(); @@ -80,12 +82,12 @@ export function EQValueEditSheet({ {valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'} - + Cancel - !p.isCustom); const custom = presets.filter((p) => p.isCustom); @@ -76,7 +78,7 @@ export function PresetSheet({ onClose(); }} trailing={ - onDelete(p.id)} style={styles.delete} diff --git a/src/components/eq/SavePresetSheet.tsx b/src/components/eq/SavePresetSheet.tsx index 5e4cc35..52eb534 100644 --- a/src/components/eq/SavePresetSheet.tsx +++ b/src/components/eq/SavePresetSheet.tsx @@ -12,6 +12,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; import { EqSheet } from './EqSheet'; interface SavePresetSheetProps { @@ -23,6 +24,7 @@ interface SavePresetSheetProps { /** Name + save a custom preset from the current bands/preamp. */ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const [name, setName] = useState(defaultName); const trimmed = name.trim(); @@ -50,12 +52,12 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee }} /> - + Cancel - { diff --git a/src/components/library/AlbumGridItem.tsx b/src/components/library/AlbumGridItem.tsx index 954d103..e6aff20 100644 --- a/src/components/library/AlbumGridItem.tsx +++ b/src/components/library/AlbumGridItem.tsx @@ -11,14 +11,16 @@ import { spacing, } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { albumArtworkSource } from '@/library/artwork'; import type { Album } from '@/types/library'; export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) { const styles = useStyles(); + const ripple = useRipple(); const artUri = albumArtworkSource(album); return ( - + {artUri ? ( void }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const artUri = albumArtworkSource(album); return ( - + {artUri ? ( void }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const useMosaic = artist.artwork_hashes.length >= 4; 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'}`; return ( - + {hashes.length === 0 ? ( diff --git a/src/components/library/ArtistRow.tsx b/src/components/library/ArtistRow.tsx index 44a1e97..58a08fc 100644 --- a/src/components/library/ArtistRow.tsx +++ b/src/components/library/ArtistRow.tsx @@ -11,14 +11,16 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { artworkUri } from '@/library/artwork'; import type { Artist } from '@/types/library'; export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); return ( - + {artist.artwork_hash ? ( void; }) { const styles = useStyles(); + const ripple = useRipple(); const colors = useColors(); const insets = useSafeAreaInsets(); const { width: W } = useWindowDimensions(); @@ -246,7 +248,7 @@ export function CollapsingHeader({ {heroMeta} {heroExtra} - - - - + - + {onMore ? ( - Pick a folder on this device and Astra will scan it into your library. - router.push('/settings')} accessibilityRole="button"> + router.push('/settings')} accessibilityRole="button"> Folder settings diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index 9bb715c..5492740 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -36,6 +36,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import type { DbTrack } from '@/types/library'; @@ -60,6 +61,7 @@ function FolderRow({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const { node, depth, isExpanded } = row; const play = (event: GestureResponderEvent) => { @@ -73,7 +75,8 @@ function FolderRow({ return ( [styles.folderRow, pressed && styles.rowPressed]} + android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={styles.folderRow} onPress={() => onToggle(node.id)} onLongPress={() => onOpenActions(node)} accessibilityRole="button" @@ -104,7 +107,8 @@ function FolderRow({ {node.totalTrackCount} [styles.folderButton, pressed && styles.folderButtonPressed]} + android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={styles.folderButton} onPress={play} hitSlop={6} accessibilityRole="button" @@ -113,7 +117,8 @@ function FolderRow({ [styles.folderButton, pressed && styles.folderButtonPressed]} + android_ripple={ripple.icon(20)} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={styles.folderButton} onPress={shuffle} hitSlop={6} accessibilityRole="button" @@ -136,6 +141,7 @@ function FolderTrackRow({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); const index = row.folderTracks.findIndex((track) => track.path === row.track.path); const playFolderTrack = () => { @@ -148,11 +154,8 @@ function FolderTrackRow({ return ( [ - styles.trackRow, - active && styles.trackRowActive, - pressed && styles.rowPressed, - ]} + android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={[styles.trackRow, active && styles.trackRowActive]} onPress={playFolderTrack} onLongPress={onOpenActions} accessibilityRole="button" @@ -171,7 +174,8 @@ function FolderTrackRow({ {formatDuration(row.track.duration)} [styles.actionsButton, pressed && styles.actionsButtonPressed]} + android_ripple={ripple.icon(21)} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={styles.actionsButton} onPress={openActions} hitSlop={8} accessibilityRole="button" @@ -347,9 +351,6 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', justifyContent: 'center', }, - folderButtonPressed: { - backgroundColor: colors.glassBg, - }, trackRow: { flexDirection: 'row', alignItems: 'center', @@ -362,9 +363,6 @@ const useStyles = createThemedStyles((colors) => ({ trackRowActive: { backgroundColor: colors.accentGlow, }, - rowPressed: { - opacity: 0.72, - }, trackMeta: { flex: 1, minWidth: 0, @@ -390,9 +388,6 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', justifyContent: 'center', }, - actionsButtonPressed: { - backgroundColor: colors.glassBg, - }, empty: { flex: 1, alignItems: 'center', diff --git a/src/components/library/PlaylistRow.tsx b/src/components/library/PlaylistRow.tsx index f772e1b..e75750f 100644 --- a/src/components/library/PlaylistRow.tsx +++ b/src/components/library/PlaylistRow.tsx @@ -11,6 +11,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { artworkUri } from '@/library/artwork'; export function PlaylistRow({ @@ -39,8 +40,10 @@ export function PlaylistRow({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); return ( s.playlists); @@ -205,7 +207,7 @@ export function PlaylistsView({ /> - setAddSheetOpen(true)} accessibilityRole="button" diff --git a/src/components/library/SelectionActionBar.tsx b/src/components/library/SelectionActionBar.tsx index 358a969..f133210 100644 --- a/src/components/library/SelectionActionBar.tsx +++ b/src/components/library/SelectionActionBar.tsx @@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; interface SelectionActionBarProps { count: number; @@ -66,13 +67,11 @@ function BarButton({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); return ( [ - styles.button, - pressed && styles.buttonPressed, - disabled && styles.buttonDisabled, - ]} + android_ripple={ripple.bounded} + style={[styles.button, disabled && styles.buttonDisabled]} onPress={onPress} disabled={disabled} accessibilityRole="button" @@ -101,9 +100,6 @@ const useStyles = createThemedStyles((colors) => ({ gap: spacing.xs, paddingVertical: spacing.md, }, - buttonPressed: { - opacity: 0.7, - }, buttonDisabled: { opacity: 0.4, }, diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index 89dacdd..c511bd6 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -17,6 +17,7 @@ import { spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { formatDuration } from '@/lib/format'; import { trackArtworkThumbSource } from '@/library/artwork'; import { dbTrackToTrack } from '@/library/trackAdapter'; @@ -60,6 +61,7 @@ export function TrackRow({ }) { const styles = useStyles(); const colors = useColors(); + const ripple = useRipple(); // Key the artwork by hash (local) or identity path (remote) so the error fallback // and FlashList recycling work for both. const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash; @@ -74,6 +76,7 @@ export function TrackRow({ const row = ( [styles.actionsButton, pressed && styles.actionsButtonPressed]} + android_ripple={ripple.icon(ACTIONS_BUTTON / 2 + 4)} unstable_pressDelay={SCROLL_PRESS_DELAY} + style={styles.actionsButton} onPress={openActions} hitSlop={8} accessibilityRole="button" @@ -257,7 +261,4 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', justifyContent: 'center', }, - actionsButtonPressed: { - backgroundColor: colors.glassBg, - }, })); diff --git a/src/components/lyrics/LyricsBand.tsx b/src/components/lyrics/LyricsBand.tsx index 874d352..e23197a 100644 --- a/src/components/lyrics/LyricsBand.tsx +++ b/src/components/lyrics/LyricsBand.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, View, type LayoutChangeEvent } from 'react-native'; import { Text } from '@/components/Text'; import { useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { useLyricsStore } from '@/stores/lyricsStore'; import { @@ -44,6 +45,7 @@ function clamp(value: number, min: number, max: number): number { export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) { const colors = useColors(); + const ripple = useRipple(); const entry = useLyricsStore((s) => s.byPath[track.path]); const loadForTrack = useLyricsStore((s) => s.loadForTrack); @@ -228,7 +230,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: {followPaused ? ( - - + {hasFurigana ? (