mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
fix minor visual bugs
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts",
|
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/swipeableRowState.test.mts",
|
||||||
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Animated, {
|
|||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
|
import { swipeLaneOpacity } from '@/components/swipeableRowState';
|
||||||
|
|
||||||
type IconName = keyof typeof Ionicons.glyphMap;
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
@@ -125,24 +126,40 @@ export function SwipeableRow({
|
|||||||
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
||||||
|
|
||||||
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
||||||
|
const leftLaneStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: swipeLaneOpacity(tx.value, 'left'),
|
||||||
|
}));
|
||||||
|
const rightLaneStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: swipeLaneOpacity(tx.value, 'right'),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
{swipeRight ? (
|
{swipeRight ? (
|
||||||
<View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }]}
|
style={[
|
||||||
|
styles.lane,
|
||||||
|
styles.laneLeft,
|
||||||
|
{ backgroundColor: swipeRight.color },
|
||||||
|
leftLaneStyle,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
||||||
</View>
|
</Animated.View>
|
||||||
) : null}
|
) : null}
|
||||||
{swipeLeft ? (
|
{swipeLeft ? (
|
||||||
<View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }]}
|
style={[
|
||||||
|
styles.lane,
|
||||||
|
styles.laneRight,
|
||||||
|
{ backgroundColor: swipeLeft.color },
|
||||||
|
rightLaneStyle,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
||||||
</View>
|
</Animated.View>
|
||||||
) : null}
|
) : null}
|
||||||
<GestureDetector gesture={gesture}>
|
<GestureDetector gesture={gesture}>
|
||||||
<Animated.View style={contentStyle}>{children}</Animated.View>
|
<Animated.View style={contentStyle}>{children}</Animated.View>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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, {
|
||||||
|
cancelAnimation,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useReducedMotion,
|
useReducedMotion,
|
||||||
@@ -40,7 +41,10 @@ import { ScopeRack } from '@/components/player/ScopeRack';
|
|||||||
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
|
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
|
||||||
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||||
import { resolveNowPlayingDismissSpring } from '@/components/player/nowPlayingDismiss';
|
import {
|
||||||
|
resolveNowPlayingDismissSpring,
|
||||||
|
shouldEnableNowPlayingPan,
|
||||||
|
} from '@/components/player/nowPlayingDismiss';
|
||||||
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
||||||
import {
|
import {
|
||||||
NOW_PLAYING_CLOSE_COMMIT_MS,
|
NOW_PLAYING_CLOSE_COMMIT_MS,
|
||||||
@@ -146,6 +150,7 @@ export function NowPlayingOverlay() {
|
|||||||
const foreground = useAppForeground();
|
const foreground = useAppForeground();
|
||||||
const surfacesLive = playerOpen && foreground;
|
const surfacesLive = playerOpen && foreground;
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [lyricsBodySwitching, setLyricsBodySwitching] = 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), []);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -293,14 +298,6 @@ export function NowPlayingOverlay() {
|
|||||||
hasTabletCompanion,
|
hasTabletCompanion,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showLyrics = () => {
|
|
||||||
if (hasTabletCompanion) {
|
|
||||||
void setNowPlayingCompanion('lyrics');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void setLyricsVisible(!lyricsVisible);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showQueue = () => {
|
const showQueue = () => {
|
||||||
if (hasTabletCompanion) {
|
if (hasTabletCompanion) {
|
||||||
if (!isDesktopTarget) {
|
if (!isDesktopTarget) {
|
||||||
@@ -416,6 +413,34 @@ export function NowPlayingOverlay() {
|
|||||||
// both faces for the 220 ms transition, then release the invisible surface.
|
// both faces for the 220 ms transition, then release the invisible surface.
|
||||||
const stageProgress = useSharedValue(effectiveScopeStageVisible ? 1 : 0);
|
const stageProgress = useSharedValue(effectiveScopeStageVisible ? 1 : 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swapping the phone player body unmounts every normal control underneath the
|
||||||
|
* parent pan detector. Suspend that pan for the commit frame and synchronously
|
||||||
|
* restore its anchor first, so a cancelled child gesture cannot carry a stale
|
||||||
|
* translateY into the lyrics tree (or back into the standard player).
|
||||||
|
*/
|
||||||
|
const setPhoneLyricsVisible = (visible: boolean) => {
|
||||||
|
if (!playerOpen) return;
|
||||||
|
cancelAnimation(translateY);
|
||||||
|
translateY.value = 0;
|
||||||
|
setLyricsBodySwitching(true);
|
||||||
|
void setLyricsVisible(visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLyrics = () => {
|
||||||
|
if (hasTabletCompanion) {
|
||||||
|
void setNowPlayingCompanion('lyrics');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhoneLyricsVisible(!lyricsVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lyricsBodySwitching) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => setLyricsBodySwitching(false));
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [lyricsBodySwitching, lyricsMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!transitionTrackKey) return;
|
if (!transitionTrackKey) return;
|
||||||
trackProgress.value = 0;
|
trackProgress.value = 0;
|
||||||
@@ -484,7 +509,7 @@ export function NowPlayingOverlay() {
|
|||||||
// A child sheet owns vertical gestures while it is visible. Replacing this
|
// A child sheet owns vertical gestures while it is visible. Replacing this
|
||||||
// gesture during the queue-button touch used to cancel a partially active
|
// gesture during the queue-button touch used to cancel a partially active
|
||||||
// pan and leave translateY off-screen while phase still said "open".
|
// pan and leave translateY off-screen while phase still said "open".
|
||||||
.enabled(playerOpen && !queueOpen)
|
.enabled(shouldEnableNowPlayingPan(playerOpen, queueOpen, lyricsBodySwitching))
|
||||||
.activeOffsetY(14) // engage only on a downward drag
|
.activeOffsetY(14) // engage only on a downward drag
|
||||||
.failOffsetY(-14)
|
.failOffsetY(-14)
|
||||||
.failOffsetX([-24, 24]) // let the horizontal seek drag through
|
.failOffsetX([-24, 24]) // let the horizontal seek drag through
|
||||||
@@ -526,12 +551,13 @@ export function NowPlayingOverlay() {
|
|||||||
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
|
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
|
||||||
// a player that already believes it is open still re-runs the slide-in — that
|
// a player that already believes it is open still re-runs the slide-in — that
|
||||||
// is the recovery path for a sheet stranded off-screen by an interrupted
|
// is the recovery path for a sheet stranded off-screen by an interrupted
|
||||||
// close. `queueOpen` also re-anchors the player before its modal BottomSheet
|
// close. `queueOpen` re-anchors the player before its modal BottomSheet
|
||||||
// appears. `windowHeight` is deliberately NOT a dependency: a dimension
|
// appears, and `lyricsMode` provides the same backstop after the phone body
|
||||||
// change (rotation, or an RN Modal like the output picker) would re-run this
|
// swap. `windowHeight` is deliberately NOT a dependency: a dimension change
|
||||||
// effect and cancel an in-flight exit spring. NOTE: this effect must stay
|
// (rotation, or an RN Modal like the output picker) would re-run this effect
|
||||||
// BELOW every direct `translateY.value` write — the react compiler forbids
|
// and cancel an in-flight exit spring. NOTE: this effect must stay BELOW every
|
||||||
// mutations after an effect that depends on the value.
|
// direct `translateY.value` write — the react compiler forbids mutations
|
||||||
|
// after an effect that depends on the value.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'closing') {
|
if (phase === 'closing') {
|
||||||
// The gesture and button paths own the exit animation, including its
|
// The gesture and button paths own the exit animation, including its
|
||||||
@@ -544,7 +570,7 @@ export function NowPlayingOverlay() {
|
|||||||
}
|
}
|
||||||
translateY.value = withTiming(0, { duration: 240 });
|
translateY.value = withTiming(0, { duration: 240 });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
|
||||||
}, [phase, openRequest, exitAnimated, queueOpen, translateY]);
|
}, [phase, openRequest, exitAnimated, queueOpen, lyricsMode, translateY]);
|
||||||
|
|
||||||
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
|
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
|
||||||
// animation callbacks, so a cancelled animation can never strand the phase.
|
// animation callbacks, so a cancelled animation can never strand the phase.
|
||||||
@@ -755,7 +781,7 @@ export function NowPlayingOverlay() {
|
|||||||
onNext={skipToNext}
|
onNext={skipToNext}
|
||||||
onPrev={skipToPrevious}
|
onPrev={skipToPrevious}
|
||||||
onToggleFavorite={() => void toggleFavorite(track)}
|
onToggleFavorite={() => void toggleFavorite(track)}
|
||||||
onExitLyrics={() => void setLyricsVisible(false)}
|
onExitLyrics={() => setPhoneLyricsVisible(false)}
|
||||||
onDismiss={() => dismissSheet()}
|
onDismiss={() => dismissSheet()}
|
||||||
/>
|
/>
|
||||||
) : isDesktopTarget ? (
|
) : isDesktopTarget ? (
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { resolveNowPlayingDismissSpring } from './nowPlayingDismiss.ts';
|
import {
|
||||||
|
resolveNowPlayingDismissSpring,
|
||||||
|
shouldEnableNowPlayingPan,
|
||||||
|
} from './nowPlayingDismiss.ts';
|
||||||
|
|
||||||
|
test('player pan yields to child sheets and body replacements', () => {
|
||||||
|
assert.equal(shouldEnableNowPlayingPan(true, false, false), true);
|
||||||
|
assert.equal(shouldEnableNowPlayingPan(true, true, false), false);
|
||||||
|
assert.equal(shouldEnableNowPlayingPan(true, false, true), false);
|
||||||
|
assert.equal(shouldEnableNowPlayingPan(false, false, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('preserves ordinary downward release velocity and spring', () => {
|
test('preserves ordinary downward release velocity and spring', () => {
|
||||||
for (const velocity of [0, 1, 500, 999, 1000]) {
|
for (const velocity of [0, 1, 500, 999, 1000]) {
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ interface NowPlayingDismissSpring {
|
|||||||
damping: number;
|
damping: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overlay pan must briefly yield when its body is being replaced. Otherwise
|
||||||
|
* RNGH can cancel the old child tree after it has already written a partial
|
||||||
|
* translateY, leaving the newly mounted body parked off-screen.
|
||||||
|
*/
|
||||||
|
export function shouldEnableNowPlayingPan(
|
||||||
|
playerOpen: boolean,
|
||||||
|
childSheetOpen: boolean,
|
||||||
|
bodySwitching: boolean
|
||||||
|
): boolean {
|
||||||
|
return playerOpen && !childSheetOpen && !bodySwitching;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preserve the exact release velocity, then make the spring progressively
|
* Preserve the exact release velocity, then make the spring progressively
|
||||||
* stronger and more damped for hard flicks so it sheds speed after handoff.
|
* stronger and more damped for hard flicks so it sheds speed after handoff.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { swipeLaneOpacity } from './swipeableRowState.ts';
|
||||||
|
|
||||||
|
test('swipe action lanes stay transparent while a row is resting', () => {
|
||||||
|
assert.equal(swipeLaneOpacity(0, 'left'), 0);
|
||||||
|
assert.equal(swipeLaneOpacity(0, 'right'), 0);
|
||||||
|
assert.equal(swipeLaneOpacity(Number.NaN, 'left'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the lane in the active swipe direction becomes visible', () => {
|
||||||
|
assert.equal(swipeLaneOpacity(2, 'left'), 1);
|
||||||
|
assert.equal(swipeLaneOpacity(2, 'right'), 0);
|
||||||
|
assert.equal(swipeLaneOpacity(-2, 'left'), 0);
|
||||||
|
assert.equal(swipeLaneOpacity(-2, 'right'), 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type SwipeLaneSide = 'left' | 'right';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action lanes sit underneath an opaque row and should only exist visually
|
||||||
|
* while that row is moving toward them. Keeping them opaque at rest lets a
|
||||||
|
* parent scene-opacity animation reveal both half-width lane colors through the
|
||||||
|
* row on Android.
|
||||||
|
*/
|
||||||
|
export function swipeLaneOpacity(
|
||||||
|
translationX: number,
|
||||||
|
side: SwipeLaneSide
|
||||||
|
): number {
|
||||||
|
'worklet';
|
||||||
|
if (!Number.isFinite(translationX)) return 0;
|
||||||
|
return side === 'left'
|
||||||
|
? translationX > 1 ? 1 : 0
|
||||||
|
: translationX < -1 ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user