fix internal routing, and remove softlocking

This commit is contained in:
Boof2015
2026-07-25 11:12:02 -04:00
parent 098992639d
commit 0f34ca2e8d
33 changed files with 904 additions and 157 deletions
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
commitPlayerClosed,
initialPlayerPresence,
isPlayerMounted,
isPlayerOnScreen,
requestPlayerClose,
requestPlayerOpen,
settlePlayerOpen,
type PlayerPresenceState,
} from './playerPresence.ts';
test('a normal open/close round trip settles at both ends', () => {
let state = initialPlayerPresence;
assert.equal(isPlayerMounted(state.phase), false);
state = requestPlayerOpen(state);
assert.equal(state.phase, 'opening');
assert.equal(isPlayerMounted(state.phase), true, 'overlay mounts as soon as it is requested');
assert.equal(isPlayerOnScreen(state.phase), true);
state = settlePlayerOpen(state);
assert.equal(state.phase, 'open');
state = requestPlayerClose(state);
assert.equal(state.phase, 'closing');
assert.equal(isPlayerMounted(state.phase), true, 'stays mounted for the exit animation');
assert.equal(isPlayerOnScreen(state.phase), false);
state = commitPlayerClosed(state);
assert.equal(state.phase, 'closed');
assert.equal(isPlayerMounted(state.phase), false);
});
test('reopening an already-open player still registers as a request', () => {
// The regression this guards: `openPlayer` used to be `set({ open: true })`,
// so tapping the mini-player while the flag was already true produced no
// state change, no re-render, and no enter animation. If the sheet had been
// stranded off-screen by an interrupted close, it could never be reopened.
const open: PlayerPresenceState = { phase: 'open', openRequest: 4, exitAnimated: false };
const reopened = requestPlayerOpen(open);
assert.equal(reopened.phase, 'opening');
assert.notEqual(reopened.openRequest, open.openRequest, 'must be a real state change');
});
test('the close request records whether an exit animation is already running', () => {
const open = settlePlayerOpen(requestPlayerOpen(initialPlayerPresence));
// Gesture / chevron: the caller drives the sheet with its own velocity-matched
// spring, so the overlay must not start a competing slide-out.
assert.equal(requestPlayerClose(open, true).exitAnimated, true);
// Anything else (e.g. the output picker pushing a route underneath) needs the
// overlay to animate the sheet away itself.
assert.equal(requestPlayerClose(open).exitAnimated, false);
// A reopen clears it so the next close starts from a known state.
assert.equal(requestPlayerOpen(requestPlayerClose(open, true)).exitAnimated, false);
});
test('closing does not depend on the exit animation completing', () => {
// A cancelled spring never reports completion, so the fallback timer commits
// the release instead. Both routes end in the same place.
let state = requestPlayerOpen(initialPlayerPresence);
state = requestPlayerClose(state);
assert.equal(state.phase, 'closing');
state = commitPlayerClosed(state);
assert.equal(state.phase, 'closed', 'released with no animation callback involved');
});
test('a stale close commit cannot yank back a player the user reopened', () => {
// Reopening mid-close leaves the fallback timer from the previous close in
// flight. Committing must be a no-op unless still closing.
let state = requestPlayerOpen(initialPlayerPresence);
state = requestPlayerClose(state);
state = requestPlayerOpen(state);
assert.equal(state.phase, 'opening');
const afterStaleCommit = commitPlayerClosed(state);
assert.equal(afterStaleCommit.phase, 'opening', 'late commit is ignored');
assert.equal(isPlayerMounted(afterStaleCommit.phase), true);
});
test('a stale open settle cannot resurrect a closing player', () => {
let state = requestPlayerOpen(initialPlayerPresence);
state = requestPlayerClose(state);
assert.equal(settlePlayerOpen(state).phase, 'closing');
});
test('closing an already-closed player is inert', () => {
const closed = requestPlayerClose(initialPlayerPresence);
assert.equal(closed.phase, 'closed');
assert.equal(closed, initialPlayerPresence, 'no new state object, so no re-render');
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Now-playing presentation phases.
*
* The player is an overlay above the navigator, not a route, so nothing but this
* phase decides whether it is on screen. It used to be a bare `playerOpen`
* boolean paired with a separate mount gate and a Reanimated offset, and those
* three could disagree: a cancelled close animation left the flag `true` with
* the sheet parked off-screen, and because reopening was `set({ open: true })`
* it produced no state change and therefore no re-render — the player could
* never be reopened again for the rest of the session.
*
* Two invariants keep that from coming back:
* - every open request bumps `openRequest`, so a repeat request is always a
* real state change and always re-runs the enter animation (the repair path);
* - phase transitions never depend on an animation completing. `closing` is
* entered before the exit animation starts, and `closed` is committed by
* whichever lands first, the animation callback or a fallback timer.
*/
export type PlayerPhase = 'closed' | 'opening' | 'open' | 'closing';
export interface PlayerPresenceState {
phase: PlayerPhase;
openRequest: number;
/**
* Whether the current close already has an exit animation attached. The
* gesture and button paths drive the sheet themselves (with velocity-matched
* spring shaping), so the overlay must not start a competing one; a close
* requested from anywhere else gets a plain slide-out instead.
*/
exitAnimated: boolean;
}
export const initialPlayerPresence: PlayerPresenceState = {
phase: 'closed',
openRequest: 0,
exitAnimated: false,
};
/**
* Ask for the player. Unconditional on purpose: requesting an open while the
* phase already says `open` still bumps `openRequest`, which is what lets a tap
* recover a sheet that was stranded off-screen by an interrupted close.
*/
export function requestPlayerOpen(state: PlayerPresenceState): PlayerPresenceState {
return { phase: 'opening', openRequest: state.openRequest + 1, exitAnimated: false };
}
/** Begin the exit. Safe to call repeatedly; a closed player stays closed. */
export function requestPlayerClose(
state: PlayerPresenceState,
exitAnimated = false
): PlayerPresenceState {
if (state.phase === 'closed') return state;
return { ...state, phase: 'closing', exitAnimated };
}
/**
* Release the overlay. Ignored unless still closing, so a fallback timer that
* fires after the user reopened the player cannot yank it back off screen.
*/
export function commitPlayerClosed(state: PlayerPresenceState): PlayerPresenceState {
if (state.phase !== 'closing') return state;
return { ...state, phase: 'closed' };
}
/** Settle the enter animation. Cosmetic only — nothing gates on it. */
export function settlePlayerOpen(state: PlayerPresenceState): PlayerPresenceState {
if (state.phase !== 'opening') return state;
return { ...state, phase: 'open' };
}
/** Whether the heavyweight overlay tree should be mounted. */
export function isPlayerMounted(phase: PlayerPhase): boolean {
return phase !== 'closed';
}
/** Whether the sheet should be resting on screen (as opposed to sliding away). */
export function isPlayerOnScreen(phase: PlayerPhase): boolean {
return phase === 'opening' || phase === 'open';
}
+38 -9
View File
@@ -1,18 +1,47 @@
import { create } from 'zustand';
import {
commitPlayerClosed,
initialPlayerPresence,
isPlayerMounted,
isPlayerOnScreen,
requestPlayerClose,
requestPlayerOpen,
settlePlayerOpen,
type PlayerPresenceState,
} from '@/stores/playerPresence';
/**
* Now-playing overlay gate. The player is an overlay above the navigator (not
* a route); its host retains it only long enough to finish the close animation.
* Session state only — never persisted.
* Now-playing overlay gate. The player is an overlay above the navigator (not a
* route), and this phase is the only thing that decides whether it is mounted
* and on screen. See `playerPresence.ts` for the invariants. Session state only
* — never persisted.
*/
interface PlayerUiStore {
playerOpen: boolean;
interface PlayerUiStore extends PlayerPresenceState {
openPlayer: () => void;
closePlayer: () => void;
/**
* @param exitAnimated pass true when the caller is already animating the sheet
* away, so the overlay does not start a competing slide-out.
*/
closePlayer: (exitAnimated?: boolean) => void;
commitClosed: () => void;
settleOpen: () => void;
}
export const usePlayerUiStore = create<PlayerUiStore>((set) => ({
playerOpen: false,
openPlayer: () => set({ playerOpen: true }),
closePlayer: () => set({ playerOpen: false }),
...initialPlayerPresence,
openPlayer: () => set(requestPlayerOpen),
closePlayer: (exitAnimated = false) =>
set((state) => requestPlayerClose(state, exitAnimated)),
commitClosed: () => set(commitPlayerClosed),
settleOpen: () => set(settlePlayerOpen),
}));
/** Whether the overlay tree should be rendered at all. */
export function usePlayerMounted(): boolean {
return usePlayerUiStore((s) => isPlayerMounted(s.phase));
}
/** Whether the sheet should be resting on screen rather than sliding away. */
export function usePlayerOnScreen(): boolean {
return usePlayerUiStore((s) => isPlayerOnScreen(s.phase));
}