mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
fix internal routing, and remove softlocking
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user