diff --git a/package.json b/package.json index 6b2a1ac..7b2296d 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "ios": "expo run:ios", "web": "expo start --web", "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: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", diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 9b0bad3..5dea81e 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -21,6 +21,7 @@ import Animated, { import { useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; import { playHaptic } from '@/lib/haptics'; +import { swipeLaneOpacity } from '@/components/swipeableRowState'; type IconName = keyof typeof Ionicons.glyphMap; @@ -125,24 +126,40 @@ export function SwipeableRow({ const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan; const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] })); + const leftLaneStyle = useAnimatedStyle(() => ({ + opacity: swipeLaneOpacity(tx.value, 'left'), + })); + const rightLaneStyle = useAnimatedStyle(() => ({ + opacity: swipeLaneOpacity(tx.value, 'right'), + })); return ( {swipeRight ? ( - - + ) : null} {swipeLeft ? ( - - + ) : null} {children} diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 8d47f94..025e811 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -12,6 +12,7 @@ import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { + cancelAnimation, runOnJS, useAnimatedStyle, useReducedMotion, @@ -40,7 +41,10 @@ import { ScopeRack } from '@/components/player/ScopeRack'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; 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 { NOW_PLAYING_CLOSE_COMMIT_MS, @@ -146,6 +150,7 @@ export function NowPlayingOverlay() { const foreground = useAppForeground(); const surfacesLive = playerOpen && foreground; const [queueOpen, setQueueOpen] = useState(false); + const [lyricsBodySwitching, setLyricsBodySwitching] = useState(false); // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. const closeQueue = useCallback(() => setQueueOpen(false), []); const [menuOpen, setMenuOpen] = useState(false); @@ -293,14 +298,6 @@ export function NowPlayingOverlay() { hasTabletCompanion, ]); - const showLyrics = () => { - if (hasTabletCompanion) { - void setNowPlayingCompanion('lyrics'); - return; - } - void setLyricsVisible(!lyricsVisible); - }; - const showQueue = () => { if (hasTabletCompanion) { if (!isDesktopTarget) { @@ -416,6 +413,34 @@ export function NowPlayingOverlay() { // both faces for the 220 ms transition, then release the invisible surface. 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(() => { if (!transitionTrackKey) return; trackProgress.value = 0; @@ -484,7 +509,7 @@ export function NowPlayingOverlay() { // A child sheet owns vertical gestures while it is visible. Replacing this // gesture during the queue-button touch used to cancel a partially active // 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 .failOffsetY(-14) .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 // 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 - // close. `queueOpen` also re-anchors the player before its modal BottomSheet - // appears. `windowHeight` is deliberately NOT a dependency: a dimension - // change (rotation, or an RN Modal like the output picker) would re-run this - // effect and cancel an in-flight exit spring. NOTE: this effect must stay - // BELOW every direct `translateY.value` write — the react compiler forbids - // mutations after an effect that depends on the value. + // close. `queueOpen` re-anchors the player before its modal BottomSheet + // appears, and `lyricsMode` provides the same backstop after the phone body + // swap. `windowHeight` is deliberately NOT a dependency: a dimension change + // (rotation, or an RN Modal like the output picker) would re-run this effect + // and cancel an in-flight exit spring. 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 (phase === 'closing') { // The gesture and button paths own the exit animation, including its @@ -544,7 +570,7 @@ export function NowPlayingOverlay() { } translateY.value = withTiming(0, { duration: 240 }); // 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 // animation callbacks, so a cancelled animation can never strand the phase. @@ -755,7 +781,7 @@ export function NowPlayingOverlay() { onNext={skipToNext} onPrev={skipToPrevious} onToggleFavorite={() => void toggleFavorite(track)} - onExitLyrics={() => void setLyricsVisible(false)} + onExitLyrics={() => setPhoneLyricsVisible(false)} onDismiss={() => dismissSheet()} /> ) : isDesktopTarget ? ( diff --git a/src/components/player/nowPlayingDismiss.test.mts b/src/components/player/nowPlayingDismiss.test.mts index 1db7a33..ab05666 100644 --- a/src/components/player/nowPlayingDismiss.test.mts +++ b/src/components/player/nowPlayingDismiss.test.mts @@ -1,7 +1,17 @@ import assert from 'node:assert/strict'; 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', () => { for (const velocity of [0, 1, 500, 999, 1000]) { diff --git a/src/components/player/nowPlayingDismiss.ts b/src/components/player/nowPlayingDismiss.ts index 4df09c1..a22b406 100644 --- a/src/components/player/nowPlayingDismiss.ts +++ b/src/components/player/nowPlayingDismiss.ts @@ -13,6 +13,19 @@ interface NowPlayingDismissSpring { 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 * stronger and more damped for hard flicks so it sheds speed after handoff. diff --git a/src/components/swipeableRowState.test.mts b/src/components/swipeableRowState.test.mts new file mode 100644 index 0000000..1802a3e --- /dev/null +++ b/src/components/swipeableRowState.test.mts @@ -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); +}); diff --git a/src/components/swipeableRowState.ts b/src/components/swipeableRowState.ts new file mode 100644 index 0000000..d343dac --- /dev/null +++ b/src/components/swipeableRowState.ts @@ -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; +}