mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
scopes styles
This commit is contained in:
@@ -16,6 +16,8 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withSpring,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
@@ -34,6 +36,7 @@ import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||
import { TactilePressable } from '@/components/player/TactilePressable';
|
||||
import { ScopeRack } from '@/components/player/ScopeRack';
|
||||
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
|
||||
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
NOW_PLAYING_CONTENT_TOP_PADDING,
|
||||
NOW_PLAYING_HEADER_HEIGHT,
|
||||
NOW_PLAYING_PLAY_BUTTON_SIZE,
|
||||
NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP,
|
||||
NOW_PLAYING_SUB_BUTTON_SIZE,
|
||||
NOW_PLAYING_WAVEFORM_TOUCH_PADDING,
|
||||
NOW_PLAYING_WIDE_PANE_GAP,
|
||||
@@ -64,7 +68,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import {
|
||||
cycleRepeat,
|
||||
@@ -124,6 +128,7 @@ export function NowPlayingOverlay() {
|
||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||
const scopeStyle = useSettingsStore((s) => s.nowPlayingScopeStyle);
|
||||
const lyricsVisible = useSettingsStore((s) => s.lyricsVisible);
|
||||
const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible);
|
||||
const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion);
|
||||
@@ -170,15 +175,19 @@ export function NowPlayingOverlay() {
|
||||
);
|
||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||
const effectiveWidth = windowWidth - insets.left - insets.right;
|
||||
// The rack style swaps the art card's face in place, so only the rail style
|
||||
// reserves stage height for a scope strip below the art.
|
||||
const railStyle = scopeStyle === 'rail';
|
||||
const layoutScopeVisible = isDesktopTarget ? false : scopeStageVisible && railStyle;
|
||||
const standardLayout = getNowPlayingLayout(
|
||||
effectiveWidth,
|
||||
availableHeight,
|
||||
isDesktopTarget ? false : scopeStageVisible
|
||||
layoutScopeVisible
|
||||
);
|
||||
const tabletCompanionLayout = getTabletCompanionLayout(
|
||||
effectiveWidth,
|
||||
availableHeight,
|
||||
isDesktopTarget ? false : scopeStageVisible
|
||||
layoutScopeVisible
|
||||
);
|
||||
const hasTabletCompanion = tabletCompanionLayout !== null;
|
||||
const lyricPeekEnabled = !isDesktopTarget && availableHeight >= 720;
|
||||
@@ -255,6 +264,11 @@ export function NowPlayingOverlay() {
|
||||
setQueueOpen(true);
|
||||
};
|
||||
|
||||
const swapScopeMode = () =>
|
||||
useSettingsStore
|
||||
.getState()
|
||||
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum');
|
||||
|
||||
const navigateToArtist = (targetArtist = artistName, credit = false) => {
|
||||
if (!targetArtist) return;
|
||||
// Slide the overlay away while the library detail loads underneath.
|
||||
@@ -323,12 +337,21 @@ export function NowPlayingOverlay() {
|
||||
const translateY = useSharedValue(windowHeight);
|
||||
const menuProgress = useSharedValue(0);
|
||||
const trackProgress = useSharedValue(1);
|
||||
// ∿ engagement, shared by both scope styles: rail = art shrink + strip fade,
|
||||
// rack = art face crossfading to the instrument rack. The scope surface stays
|
||||
// mounted either way (its frame loops idle while hidden), so visibility is
|
||||
// purely this value — no mount state to juggle.
|
||||
const stageProgress = useSharedValue(scopeStageVisible ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transitionTrackKey) return;
|
||||
trackProgress.value = 0;
|
||||
trackProgress.value = withTiming(1, { ...motion.snap, duration: 200 });
|
||||
}, [trackProgress, transitionTrackKey]);
|
||||
|
||||
useEffect(() => {
|
||||
stageProgress.value = withTiming(scopeStageVisible ? 1 : 0, motion.snap);
|
||||
}, [scopeStageVisible, stageProgress]);
|
||||
// Closing is a store toggle, not navigation. Reset the inner layers so a
|
||||
// reopen starts from the plain player (parity with the old per-open mount).
|
||||
const dismiss = () => {
|
||||
@@ -447,6 +470,51 @@ export function NowPlayingOverlay() {
|
||||
transform: [{ translateY: 4 * (1 - trackProgress.value) }],
|
||||
}));
|
||||
|
||||
// Rail choreography (standard presentation only): the art box is laid out at
|
||||
// its scope-off size and driven to the scope-on size/position by
|
||||
// stageProgress, so the ∿ toggle animates as one move instead of snapping
|
||||
// layout. Wide windows keep the in-flow snap — their stage height is
|
||||
// state-dependent by design.
|
||||
const railChoreographed = railStyle && !layout.isWide && !isDesktopTarget;
|
||||
const artBoxSize = railChoreographed ? layout.artSizeScopeOff : layout.artSize;
|
||||
const railArtScale =
|
||||
railChoreographed && layout.artSizeScopeOff > 0
|
||||
? layout.artSizeScopeOn / layout.artSizeScopeOff
|
||||
: 1;
|
||||
const railArtShift = railChoreographed
|
||||
? layout.artSizeScopeOn / 2 - layout.mediaStackHeight / 2
|
||||
: 0;
|
||||
const artStageTransitionStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.75 + trackProgress.value * 0.25,
|
||||
transform: [
|
||||
{ translateY: stageProgress.value * railArtShift },
|
||||
{
|
||||
scale:
|
||||
(0.985 + trackProgress.value * 0.015) *
|
||||
(1 + stageProgress.value * (railArtScale - 1)),
|
||||
},
|
||||
],
|
||||
}));
|
||||
const railSurfaceStyle = useAnimatedStyle(() => ({
|
||||
opacity: stageProgress.value,
|
||||
transform: [{ translateY: (1 - stageProgress.value) * 10 }],
|
||||
}));
|
||||
// Rack face flip: the art face fades out while the instrument rack settles in.
|
||||
const rackFaceStyle = useAnimatedStyle(() => ({
|
||||
opacity: stageProgress.value,
|
||||
transform: [{ scale: 0.98 + stageProgress.value * 0.02 }],
|
||||
}));
|
||||
// Always write the artwork opacity. Removing an animated style after Rack
|
||||
// faded it to zero leaves that native value behind until the view remounts.
|
||||
// Rail therefore explicitly restores the face instead of relying on the
|
||||
// absence of Rack's fade style.
|
||||
const artFaceStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: railStyle ? 1 : 1 - stageProgress.value,
|
||||
}),
|
||||
[railStyle]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
|
||||
<GestureDetector gesture={pan}>
|
||||
@@ -860,7 +928,7 @@ export function NowPlayingOverlay() {
|
||||
<View
|
||||
style={[
|
||||
styles.middleStack,
|
||||
!layout.isWide && !scopeStageVisible && styles.middleStackCentered,
|
||||
!layout.isWide && styles.middleStackCentered,
|
||||
layout.isWide
|
||||
? { width: layout.leftPaneWidth, justifyContent: 'center' }
|
||||
: {
|
||||
@@ -873,19 +941,20 @@ export function NowPlayingOverlay() {
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.artButton,
|
||||
artworkTransitionStyle,
|
||||
artStageTransitionStyle,
|
||||
{
|
||||
width: layout.artSize,
|
||||
height: layout.artSize,
|
||||
width: artBoxSize,
|
||||
height: artBoxSize,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.artCard,
|
||||
artFaceStyle,
|
||||
{
|
||||
width: layout.artSize,
|
||||
height: layout.artSize,
|
||||
width: artBoxSize,
|
||||
height: artBoxSize,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -897,12 +966,48 @@ export function NowPlayingOverlay() {
|
||||
transition={reduceMotion ? null : 200}
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
|
||||
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
{!railStyle && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.rackFace, rackFaceStyle]}
|
||||
>
|
||||
<ScopeRack
|
||||
size={artBoxSize}
|
||||
stripWidth={layout.scopeWidth}
|
||||
artworkUri={track.artworkData ?? null}
|
||||
paused={!playerOpen || queueOpen || !scopeStageVisible}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{scopeStageVisible && (
|
||||
{railStyle && !layout.isWide && (
|
||||
<Animated.View
|
||||
pointerEvents={scopeStageVisible ? 'auto' : 'none'}
|
||||
style={[
|
||||
styles.scopeRailFloating,
|
||||
railSurfaceStyle,
|
||||
{
|
||||
width: layout.scopeWidth,
|
||||
height: layout.scopeHeight,
|
||||
bottom: NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ScopeRail
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
mode={scopeMode}
|
||||
paused={!playerOpen || queueOpen || !scopeStageVisible}
|
||||
revealed={scopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
{railStyle && layout.isWide && scopeStageVisible && (
|
||||
<View
|
||||
style={[
|
||||
styles.scopeRail,
|
||||
@@ -914,34 +1019,14 @@ export function NowPlayingOverlay() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Visualizer
|
||||
<ScopeRail
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
interactive={false}
|
||||
showChrome={false}
|
||||
mode={scopeMode}
|
||||
edgeFade
|
||||
paused={!playerOpen || queueOpen}
|
||||
revealed={scopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
<TactilePressable
|
||||
onPress={() =>
|
||||
useSettingsStore
|
||||
.getState()
|
||||
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
|
||||
}
|
||||
haptic="selection"
|
||||
hitSlop={12}
|
||||
style={styles.scopeSwap} android_ripple={ripple.icon(24)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Showing ${
|
||||
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
|
||||
}. Tap to switch.`}
|
||||
>
|
||||
<Text variant="caption" style={styles.scopeSwapLabel}>
|
||||
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||
</Text>
|
||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||
</TactilePressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -1267,6 +1352,72 @@ export function NowPlayingOverlay() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ScopeRailProps {
|
||||
width: number;
|
||||
height: number;
|
||||
mode: ScopeMode;
|
||||
paused: boolean;
|
||||
/** Whether the rail is currently shown — drives the transient label hint. */
|
||||
revealed: boolean;
|
||||
onSwap: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rail-style scope strip. The whole surface swaps SPECTRUM ⇄ SCOPE on tap; the
|
||||
* mode label only appears transiently (on reveal and on swap) so the rail reads
|
||||
* as an instrument, not a labelled widget.
|
||||
*/
|
||||
function ScopeRail({ width, height, mode, paused, revealed, onSwap }: ScopeRailProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const labelOpacity = useSharedValue(0);
|
||||
// Swap presses bump the nonce; the flash itself lives in the effect because
|
||||
// the compiler forbids direct shared-value writes after an effect uses one.
|
||||
const [flashNonce, setFlashNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed) return;
|
||||
void flashNonce;
|
||||
labelOpacity.value = withSequence(
|
||||
withTiming(1, { duration: 160 }),
|
||||
withDelay(1400, withTiming(0, { duration: 420 }))
|
||||
);
|
||||
}, [flashNonce, labelOpacity, revealed]);
|
||||
const labelStyle = useAnimatedStyle(() => ({ opacity: labelOpacity.value }));
|
||||
|
||||
return (
|
||||
<TactilePressable
|
||||
onPress={() => {
|
||||
void onSwap();
|
||||
setFlashNonce((n) => n + 1);
|
||||
}}
|
||||
haptic="selection"
|
||||
pressedScale={0.99}
|
||||
style={[styles.scopeRailSurface, { width, height }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Showing ${
|
||||
mode === 'spectrum' ? 'spectrum' : 'oscilloscope'
|
||||
}. Tap to switch.`}
|
||||
>
|
||||
<Visualizer
|
||||
width={width}
|
||||
height={height}
|
||||
interactive={false}
|
||||
showChrome={false}
|
||||
mode={mode}
|
||||
edgeFade
|
||||
paused={paused}
|
||||
/>
|
||||
<Animated.View pointerEvents="none" style={[styles.scopeSwap, labelStyle]}>
|
||||
<Text variant="caption" style={styles.scopeSwapLabel}>
|
||||
{mode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
|
||||
</Text>
|
||||
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
|
||||
</Animated.View>
|
||||
</TactilePressable>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -1417,6 +1568,25 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Standard-presentation rail: pinned to the stage bottom so it can stay
|
||||
// mounted (and positioned) while its reveal animates. The parent's
|
||||
// alignItems centers it horizontally.
|
||||
scopeRailFloating: {
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
scopeRailSurface: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Rack-style face over the art card frame. Deliberately unclipped: the
|
||||
// backdrop rounds/clips itself, while the strips overflow the card.
|
||||
rackFace: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scopeSwap: {
|
||||
position: 'absolute',
|
||||
top: spacing.xs,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import {
|
||||
Blur,
|
||||
BlurMask,
|
||||
Canvas,
|
||||
Group,
|
||||
Image as SkiaImage,
|
||||
Mask,
|
||||
RoundedRect,
|
||||
useImage,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { OscilloscopeWave } from '@/components/OscilloscopeWave';
|
||||
import { SpectrumCurve } from '@/components/SpectrumCurve';
|
||||
import { getScopeHeight } from '@/components/player/nowPlayingLayout';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
|
||||
// 60fps cap, matching Visualizer — display-sync starved the JS thread on
|
||||
// high-refresh devices.
|
||||
const STAGE_FRAME_MS = 16;
|
||||
// The rack artwork is atmosphere, not a second cover card. It bleeds beyond
|
||||
// the old frame, stays softly defocused, and contributes restrained color.
|
||||
const BACKDROP_BLUR_RADIUS = 10;
|
||||
const BACKDROP_BLEED = 40;
|
||||
const BACKDROP_IMAGE_OPACITY = 0.66;
|
||||
const BACKDROP_MASK_INSET_RATIO = 0.16;
|
||||
const BACKDROP_MASK_BLUR_RATIO = 0.08;
|
||||
// Wide dissolve so the overflowing strips melt out instead of hard-stopping.
|
||||
const STRIP_EDGE_FADE_WIDTH = 56;
|
||||
|
||||
interface ScopeRackProps {
|
||||
/** Former art-card edge length; the ambient backdrop bleeds beyond it. */
|
||||
size: number;
|
||||
/** Strip span (the rail's width): wider than the card, overflowing it. */
|
||||
stripWidth: number;
|
||||
artworkUri: string | null;
|
||||
/** Freeze the scopes without unmounting (overlay closed / queue open). */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rack-style scope face: both instruments stacked at their natural wide aspect
|
||||
* (oscilloscope above, spectrum grounded below). The blurred, dimmed artwork
|
||||
* backdrop dissolves past the former card frame, while the strips span the
|
||||
* full rail width and independently fade at their ends.
|
||||
*/
|
||||
export function ScopeRack({ size, stripWidth, artworkUri, paused = false }: ScopeRackProps) {
|
||||
const styles = useStyles();
|
||||
const artwork = useImage(artworkUri);
|
||||
const active = useScopeActive() && !paused;
|
||||
const width = Math.max(0, stripWidth);
|
||||
const stripHeight = getScopeHeight(width);
|
||||
const backdropSize = size + BACKDROP_BLEED * 2;
|
||||
const maskInset = backdropSize * BACKDROP_MASK_INSET_RATIO;
|
||||
const maskBlur = backdropSize * BACKDROP_MASK_BLUR_RATIO;
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<View
|
||||
style={[
|
||||
styles.backdrop,
|
||||
{
|
||||
top: -BACKDROP_BLEED,
|
||||
left: -BACKDROP_BLEED,
|
||||
width: backdropSize,
|
||||
height: backdropSize,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{artwork ? (
|
||||
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
<Mask
|
||||
mode="alpha"
|
||||
mask={
|
||||
<RoundedRect
|
||||
x={maskInset}
|
||||
y={maskInset}
|
||||
width={backdropSize - maskInset * 2}
|
||||
height={backdropSize - maskInset * 2}
|
||||
r={maskInset * 0.6}
|
||||
color="white"
|
||||
>
|
||||
<BlurMask blur={maskBlur} style="normal" />
|
||||
</RoundedRect>
|
||||
}
|
||||
>
|
||||
<Group opacity={BACKDROP_IMAGE_OPACITY}>
|
||||
<SkiaImage
|
||||
image={artwork}
|
||||
x={0}
|
||||
y={0}
|
||||
width={backdropSize}
|
||||
height={backdropSize}
|
||||
fit="cover"
|
||||
>
|
||||
<Blur blur={BACKDROP_BLUR_RADIUS} mode="clamp" />
|
||||
</SkiaImage>
|
||||
</Group>
|
||||
</Mask>
|
||||
</Canvas>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={[styles.strips, { width, left: (size - width) / 2 }]}>
|
||||
<OscilloscopeWave
|
||||
active={active}
|
||||
frameMs={STAGE_FRAME_MS}
|
||||
width={width}
|
||||
height={stripHeight}
|
||||
glow
|
||||
edgeFade
|
||||
edgeFadeWidth={STRIP_EDGE_FADE_WIDTH}
|
||||
/>
|
||||
<SpectrumCurve
|
||||
active={active}
|
||||
frameMs={STAGE_FRAME_MS}
|
||||
width={width}
|
||||
height={stripHeight}
|
||||
glow
|
||||
edgeFade
|
||||
edgeFadeWidth={STRIP_EDGE_FADE_WIDTH}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles(() => ({
|
||||
backdrop: {
|
||||
position: 'absolute',
|
||||
},
|
||||
strips: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xl,
|
||||
},
|
||||
}));
|
||||
|
||||
export default ScopeRack;
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getNowPlayingLayout,
|
||||
getScopeHeight,
|
||||
getTabletCompanionLayout,
|
||||
} from './nowPlayingLayout.ts';
|
||||
|
||||
@@ -88,6 +89,35 @@ test('adds the companion only to roomy tablet canvases', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('returns both art sizes, state-independent, matching the resolved size', () => {
|
||||
for (const [width, height] of [
|
||||
[320, 568],
|
||||
[360, 640],
|
||||
[393, 852],
|
||||
[412, 915],
|
||||
[600, 840],
|
||||
[800, 600],
|
||||
]) {
|
||||
const hidden = getNowPlayingLayout(width, height, false);
|
||||
const visible = getNowPlayingLayout(width, height, true);
|
||||
// The pair is the same regardless of the current toggle state...
|
||||
assert.equal(hidden.artSizeScopeOn, visible.artSizeScopeOn);
|
||||
assert.equal(hidden.artSizeScopeOff, visible.artSizeScopeOff);
|
||||
// ...and the state-resolved artSize picks the matching member.
|
||||
assert.equal(hidden.artSize, hidden.artSizeScopeOff);
|
||||
assert.equal(visible.artSize, visible.artSizeScopeOn);
|
||||
assert.ok(hidden.artSizeScopeOn <= hidden.artSizeScopeOff);
|
||||
}
|
||||
});
|
||||
|
||||
test('clamps scope strip height to its band across widths', () => {
|
||||
assert.equal(getScopeHeight(0), 84);
|
||||
assert.equal(getScopeHeight(300), 84);
|
||||
assert.equal(getScopeHeight(336), Math.round(336 * 0.28));
|
||||
assert.equal(getScopeHeight(448), 108);
|
||||
assert.equal(getScopeHeight(10000), 108);
|
||||
});
|
||||
|
||||
test('keeps calculated dimensions finite and non-negative', () => {
|
||||
for (const [width, height] of [
|
||||
[320, 568],
|
||||
|
||||
@@ -22,6 +22,9 @@ const VISUALIZER_BOTTOM_GAP = spacing.sm;
|
||||
const VISUALIZER_HEIGHT_MIN = 84;
|
||||
const VISUALIZER_HEIGHT_MAX = 108;
|
||||
const VISUALIZER_HEIGHT_RATIO = 0.28;
|
||||
/** Fixed rail offset from the stage bottom, state-independent so the rail can
|
||||
* stay mounted (and positioned) while its visibility animates. */
|
||||
export const NOW_PLAYING_SCOPE_RAIL_BOTTOM_GAP = VISUALIZER_BOTTOM_GAP;
|
||||
export const NOW_PLAYING_HEADER_HEIGHT = 32;
|
||||
export const NOW_PLAYING_CONTENT_TOP_PADDING = spacing.sm;
|
||||
export const NOW_PLAYING_CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||
@@ -61,6 +64,10 @@ export interface NowPlayingLayout {
|
||||
waveformHeight: number;
|
||||
mediaStackHeight: number;
|
||||
artSize: number;
|
||||
/** Art size with the scope rail shown / hidden, regardless of the current
|
||||
* state — both are returned so the rail toggle can animate between them. */
|
||||
artSizeScopeOn: number;
|
||||
artSizeScopeOff: number;
|
||||
scopeWidth: number;
|
||||
scopeHeight: number;
|
||||
visualizerTopGap: number;
|
||||
@@ -82,7 +89,8 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getScopeHeight(scopeWidth: number): number {
|
||||
/** Natural scope strip height for a given width (~3.6:1, clamped). */
|
||||
export function getScopeHeight(scopeWidth: number): number {
|
||||
return Math.round(
|
||||
clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX)
|
||||
);
|
||||
@@ -122,11 +130,11 @@ export function getNowPlayingLayout(
|
||||
NOW_PLAYING_CONTENT_BOTTOM_PADDING -
|
||||
NOW_PLAYING_HEADER_HEIGHT -
|
||||
spacing.md;
|
||||
const artHeightBudget =
|
||||
verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0);
|
||||
const artSize = Math.round(
|
||||
clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX)
|
||||
);
|
||||
const wideArt = (budget: number) =>
|
||||
Math.round(clamp(Math.min(leftPaneWidth, budget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX));
|
||||
const artSizeScopeOn = wideArt(verticalBudget - (scopeHeight + VISUALIZER_TOP_GAP));
|
||||
const artSizeScopeOff = wideArt(verticalBudget);
|
||||
const artSize = showVisualizer ? artSizeScopeOn : artSizeScopeOff;
|
||||
const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg;
|
||||
return {
|
||||
presentation: 'wide',
|
||||
@@ -142,6 +150,8 @@ export function getNowPlayingLayout(
|
||||
? artSize + visualizerTopGap + scopeHeight
|
||||
: artSize,
|
||||
artSize,
|
||||
artSizeScopeOn,
|
||||
artSizeScopeOff,
|
||||
scopeWidth,
|
||||
scopeHeight,
|
||||
visualizerTopGap,
|
||||
@@ -214,6 +224,8 @@ export function getNowPlayingLayout(
|
||||
waveformHeight,
|
||||
mediaStackHeight,
|
||||
artSize,
|
||||
artSizeScopeOn: scopeOnArt,
|
||||
artSizeScopeOff: scopeOffArt,
|
||||
scopeWidth,
|
||||
scopeHeight,
|
||||
visualizerTopGap,
|
||||
|
||||
Reference in New Issue
Block a user