diff --git a/package.json b/package.json index 4e9c3c0..9f573c7 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts", "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", - "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", + "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index b52a41b..75703fb 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -40,6 +40,7 @@ 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 { useDelayedUnmountPresence } from '@/components/delayedPresence'; import { SleepTimerControls } from '@/components/player/SleepTimerControls'; import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet'; @@ -442,13 +443,18 @@ export function NowPlayingOverlay() { }) .onEnd((e) => { if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) { + const releaseSpring = resolveNowPlayingDismissSpring( + e.velocityY, + windowHeight - translateY.value + ); translateY.value = withSpring( windowHeight, { - damping: 28, - stiffness: 240, - velocity: e.velocityY, + damping: releaseSpring.damping, + stiffness: releaseSpring.stiffness, + velocity: releaseSpring.velocity, overshootClamping: true, + energyThreshold: 1e-4, }, (finished) => { if (finished) runOnJS(dismiss)(); diff --git a/src/components/player/nowPlayingDismiss.test.mts b/src/components/player/nowPlayingDismiss.test.mts new file mode 100644 index 0000000..1db7a33 --- /dev/null +++ b/src/components/player/nowPlayingDismiss.test.mts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveNowPlayingDismissSpring } from './nowPlayingDismiss.ts'; + +test('preserves ordinary downward release velocity and spring', () => { + for (const velocity of [0, 1, 500, 999, 1000]) { + assert.deepEqual(resolveNowPlayingDismissSpring(velocity, 760), { + velocity, + stiffness: 240, + damping: 28, + }); + } +}); + +test('normalizes upward and invalid release velocity', () => { + for (const velocity of [-1000, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.deepEqual(resolveNowPlayingDismissSpring(velocity, 760), { + velocity: 0, + stiffness: 240, + damping: 28, + }); + } +}); + +test('keeps extreme release velocity continuous while increasing spring control', () => { + const rawVelocities = [1001, 1400, 2600, 5000, 10_000]; + const springs = rawVelocities.map((velocity) => + resolveNowPlayingDismissSpring(velocity, 760) + ); + + for (let index = 0; index < springs.length; index += 1) { + assert.equal(springs[index].velocity, rawVelocities[index]); + assert.ok(springs[index].stiffness > 240); + assert.ok(springs[index].stiffness < 480); + assert.ok(springs[index].damping > 28); + assert.ok(springs[index].damping < 60); + } + + for (let index = 1; index < springs.length; index += 1) { + assert.ok(springs[index].stiffness > springs[index - 1].stiffness); + assert.ok(springs[index].damping > springs[index - 1].damping); + } + + const springAt5000 = resolveNowPlayingDismissSpring(5000, 760); + const expectedProgress = 1 - Math.exp(-1); + assert.ok(Math.abs(springAt5000.stiffness - (240 + 240 * expectedProgress)) < 1e-9); + assert.ok(Math.abs(springAt5000.damping - (28 + 32 * expectedProgress)) < 1e-9); +}); + +test('adds damping only when an outlier velocity could overshoot the remaining travel', () => { + const ordinaryFastFlick = resolveNowPlayingDismissSpring(10_000, 760); + const outlierFlick = resolveNowPlayingDismissSpring(50_000, 760); + const outlierNearBottom = resolveNowPlayingDismissSpring(50_000, 400); + + assert.ok(ordinaryFastFlick.damping < 60); + assert.equal(outlierFlick.velocity, 50_000); + assert.ok(outlierFlick.damping > 60); + assert.ok(outlierNearBottom.damping > outlierFlick.damping); +}); diff --git a/src/components/player/nowPlayingDismiss.ts b/src/components/player/nowPlayingDismiss.ts new file mode 100644 index 0000000..4df09c1 --- /dev/null +++ b/src/components/player/nowPlayingDismiss.ts @@ -0,0 +1,45 @@ +const ADAPTIVE_VELOCITY_ONSET = 1000; +const ADAPTIVE_VELOCITY_RANGE = 4000; +const BASE_STIFFNESS = 240; +const MAX_STIFFNESS = 480; +const BASE_DAMPING = 28; +const MAX_DAMPING = 60; +const MIN_REMAINING_DISTANCE = 120; +const VELOCITY_TRAVEL_DAMPING = 1.35; + +interface NowPlayingDismissSpring { + velocity: number; + stiffness: number; + damping: number; +} + +/** + * Preserve the exact release velocity, then make the spring progressively + * stronger and more damped for hard flicks so it sheds speed after handoff. + */ +export function resolveNowPlayingDismissSpring( + velocityY: number, + remainingDistance: number +): NowPlayingDismissSpring { + 'worklet'; + + const velocity = Number.isFinite(velocityY) && velocityY > 0 ? velocityY : 0; + const excessVelocity = Math.max(0, velocity - ADAPTIVE_VELOCITY_ONSET); + const adaptiveProgress = 1 - Math.exp(-excessVelocity / ADAPTIVE_VELOCITY_RANGE); + const safeRemainingDistance = Number.isFinite(remainingDistance) + ? Math.max(MIN_REMAINING_DISTANCE, remainingDistance) + : MIN_REMAINING_DISTANCE; + const adaptiveDamping = + BASE_DAMPING + (MAX_DAMPING - BASE_DAMPING) * adaptiveProgress; + // Extreme gesture velocities can otherwise cross the remaining screen travel + // in a single frame and trip overshootClamping. Scale damping only above that + // risk boundary so the already-good ordinary flick path remains unchanged. + const travelDamping = + velocity > 0 ? (velocity / safeRemainingDistance) * VELOCITY_TRAVEL_DAMPING : 0; + + return { + velocity, + stiffness: BASE_STIFFNESS + (MAX_STIFFNESS - BASE_STIFFNESS) * adaptiveProgress, + damping: Math.max(adaptiveDamping, travelDamping), + }; +}