mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
optimize further
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }]}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user