mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
update now playing screen
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import Animated, { Keyframe, ReduceMotion } from 'react-native-reanimated';
|
||||
import { TactilePressable } from '@/components/player/TactilePressable';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { peekCachedLyricsForTrack } from '@/lyrics/lyrics';
|
||||
import {
|
||||
getActiveSyncedLyricsLine,
|
||||
LYRICS_DISPLAY_LEAD_MS,
|
||||
} from '@/lyrics/presentation';
|
||||
import { fonts, spacing } from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import type { LyricsLookupResult } from '@/lyrics/types';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
const ENTERING = new Keyframe({
|
||||
0: { opacity: 0, transform: [{ translateY: 6 }] },
|
||||
100: { opacity: 1, transform: [{ translateY: 0 }] },
|
||||
})
|
||||
.duration(190)
|
||||
.reduceMotion(ReduceMotion.System);
|
||||
|
||||
const EXITING = new Keyframe({
|
||||
0: { opacity: 1, transform: [{ translateY: 0 }] },
|
||||
100: { opacity: 0, transform: [{ translateY: -6 }] },
|
||||
})
|
||||
.duration(160)
|
||||
.reduceMotion(ReduceMotion.System);
|
||||
|
||||
interface CachedLyricPeekProps {
|
||||
track: Track;
|
||||
active: boolean;
|
||||
hidden?: boolean;
|
||||
onOpenLyrics: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line synced lyric display. It consumes an existing in-memory result or a
|
||||
* cache-only SQLite read; it never initiates media scanning or provider work.
|
||||
*/
|
||||
export function CachedLyricPeek({
|
||||
track,
|
||||
active,
|
||||
hidden = false,
|
||||
onOpenLyrics,
|
||||
}: CachedLyricPeekProps) {
|
||||
const styles = useStyles();
|
||||
const memoryResult = useLyricsStore((s) => s.byPath[track.path]?.result ?? null);
|
||||
const [cached, setCached] = useState<{
|
||||
path: string;
|
||||
result: LyricsLookupResult | null;
|
||||
} | null>(null);
|
||||
const currentTime = usePlayerStore((s) => (active ? s.currentTime : 0));
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const isPlaying = usePlayerStore(
|
||||
(s) => active && s.playbackState === 'playing'
|
||||
);
|
||||
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || memoryResult) return;
|
||||
let cancelled = false;
|
||||
void peekCachedLyricsForTrack(track)
|
||||
.then((result) => {
|
||||
if (!cancelled) setCached({ path: track.path, result });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCached({ path: track.path, result: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, memoryResult, track]);
|
||||
|
||||
const storedResult = cached?.path === track.path ? cached.result : null;
|
||||
const result = memoryResult?.status === 'hit' ? memoryResult : storedResult;
|
||||
const activeLine = useMemo(() => {
|
||||
if (hidden || result?.status !== 'hit') return null;
|
||||
return getActiveSyncedLyricsLine(
|
||||
result.lyrics.syncedLines,
|
||||
smoothTime + LYRICS_DISPLAY_LEAD_MS / 1000,
|
||||
{ durationSeconds: duration }
|
||||
);
|
||||
}, [duration, hidden, result, smoothTime]);
|
||||
const text = activeLine?.text.trim() || null;
|
||||
const lineKey = text
|
||||
? `${track.path}:${activeLine?.timestampMs ?? -1}:${text}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<TactilePressable
|
||||
style={styles.pressable}
|
||||
disabled={!text}
|
||||
haptic="selection"
|
||||
onPress={onOpenLyrics}
|
||||
accessibilityRole={text ? 'button' : undefined}
|
||||
accessibilityLabel={text ? `Open lyrics: ${text}` : undefined}
|
||||
>
|
||||
{text && lineKey ? (
|
||||
<Animated.Text
|
||||
key={lineKey}
|
||||
entering={ENTERING}
|
||||
exiting={EXITING}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
style={styles.line}
|
||||
>
|
||||
{text}
|
||||
</Animated.Text>
|
||||
) : null}
|
||||
</TactilePressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
wrap: {
|
||||
height: 28,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pressable: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
line: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: fonts.sans.medium,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,99 @@
|
||||
import { View } from 'react-native';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
import { LyricsBand } from '@/components/lyrics/LyricsBand';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||
import { seekTo } from '@/audio/playbackController';
|
||||
import { tickHaptic } from '@/lib/haptics';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import type { NowPlayingCompanion } from './nowPlayingPreferences';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
const COMPANION_SEGMENTS = [
|
||||
{ key: 'queue', label: 'Queue' },
|
||||
{ key: 'lyrics', label: 'Lyrics' },
|
||||
];
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
interface NowPlayingCompanionPaneProps {
|
||||
active: boolean;
|
||||
desktopTarget: boolean;
|
||||
track: Track | null;
|
||||
}
|
||||
|
||||
/** Roomy-tablet companion rail. Phone sheets/takeovers remain separate. */
|
||||
export function NowPlayingCompanionPane({
|
||||
active,
|
||||
desktopTarget,
|
||||
track,
|
||||
}: NowPlayingCompanionPaneProps) {
|
||||
const styles = useStyles();
|
||||
const companion = useSettingsStore((s) => s.nowPlayingCompanion);
|
||||
const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
|
||||
const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0));
|
||||
const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration));
|
||||
const isPlaying = usePlayerStore(
|
||||
(s) => active && !desktopTarget && s.playbackState === 'playing'
|
||||
);
|
||||
|
||||
const selectCompanion = (next: string) => {
|
||||
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
|
||||
if (value === companion) return;
|
||||
tickHaptic();
|
||||
void setCompanion(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{desktopTarget ? (
|
||||
<RemoteQueueSheet embedded onClose={noop} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.switcher}>
|
||||
<SegmentedControl
|
||||
segments={COMPANION_SEGMENTS}
|
||||
value={companion}
|
||||
onChange={selectCompanion}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
{companion === 'queue' ? (
|
||||
<QueueTray embedded onClose={noop} />
|
||||
) : track ? (
|
||||
<LyricsBand
|
||||
track={track}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderLeftColor: colors.glassBorder,
|
||||
borderLeftWidth: 1,
|
||||
paddingLeft: spacing.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
switcher: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { motion } from '@/theme/motion';
|
||||
|
||||
interface PlayerStateIconProps {
|
||||
selected: boolean;
|
||||
size: number;
|
||||
inactive: ReactNode;
|
||||
active: ReactNode;
|
||||
}
|
||||
|
||||
/** Cross-fades transport/utility state without animating icon-font colour. */
|
||||
export function PlayerStateIcon({
|
||||
selected,
|
||||
size,
|
||||
inactive,
|
||||
active,
|
||||
}: PlayerStateIconProps) {
|
||||
const progress = useSharedValue(selected ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(selected ? 1 : 0, motion.quick);
|
||||
}, [progress, selected]);
|
||||
|
||||
const inactiveStyle = useAnimatedStyle(() => ({
|
||||
opacity: 1 - progress.value,
|
||||
}));
|
||||
const activeStyle = useAnimatedStyle(() => ({
|
||||
opacity: progress.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Animated.View style={inactiveStyle}>{inactive}</Animated.View>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, activeStyle]}>
|
||||
{active}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable press state. */
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
import { motion } from '@/theme/motion';
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
type HapticFeedback = 'selection' | 'light' | 'none';
|
||||
|
||||
interface TactilePressableProps
|
||||
extends Omit<PressableProps, 'children' | 'style'> {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
pressedScale?: number;
|
||||
confirmationScale?: number;
|
||||
haptic?: HapticFeedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now Playing press surface: restrained UI-thread compression plus one
|
||||
* best-effort haptic only after a press successfully commits.
|
||||
*/
|
||||
export function TactilePressable({
|
||||
children,
|
||||
style,
|
||||
pressedScale = 0.94,
|
||||
confirmationScale,
|
||||
haptic = 'none',
|
||||
disabled,
|
||||
onPress,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
...rest
|
||||
}: TactilePressableProps) {
|
||||
const scale = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn: NonNullable<PressableProps['onPressIn']> = (event) => {
|
||||
scale.value = withTiming(pressedScale, motion.quick);
|
||||
onPressIn?.(event);
|
||||
};
|
||||
|
||||
const handlePressOut: NonNullable<PressableProps['onPressOut']> = (event) => {
|
||||
scale.value = withTiming(1, motion.quick);
|
||||
onPressOut?.(event);
|
||||
};
|
||||
|
||||
const handlePress: NonNullable<PressableProps['onPress']> = (event) => {
|
||||
if (haptic === 'selection') tickHaptic();
|
||||
else if (haptic === 'light') commitHaptic();
|
||||
if (confirmationScale) {
|
||||
scale.value = withSequence(
|
||||
withTiming(confirmationScale, motion.quick),
|
||||
withTiming(1, motion.quick)
|
||||
);
|
||||
}
|
||||
onPress?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
style={[style, animatedStyle]}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getNowPlayingLayout,
|
||||
getTabletCompanionLayout,
|
||||
} from './nowPlayingLayout.ts';
|
||||
|
||||
const BASELINES = [
|
||||
[320, 568, false, 296, 134, 204, 58],
|
||||
[320, 568, true, 296, 96, 204, 58],
|
||||
[360, 640, false, 328, 206, 214, 58],
|
||||
[360, 640, true, 328, 96, 214, 58],
|
||||
[393, 852, false, 361, 361, 361, 76],
|
||||
[393, 852, true, 361, 234, 361, 76],
|
||||
[412, 915, false, 380, 380, 380, 82],
|
||||
[412, 915, true, 380, 248, 380, 82],
|
||||
[600, 840, false, 520, 394, 394, 58],
|
||||
[600, 840, true, 520, 262, 394, 58],
|
||||
[800, 600, false, 768, 383, 383, 58],
|
||||
[800, 600, true, 768, 383, 506, 58],
|
||||
] as const;
|
||||
|
||||
test('preserves existing non-companion media geometry', () => {
|
||||
for (const [width, height, visualizer, contentWidth, artSize, mediaHeight, waveform] of BASELINES) {
|
||||
const layout = getNowPlayingLayout(width, height, visualizer);
|
||||
assert.deepEqual(
|
||||
[layout.contentWidth, layout.artSize, layout.mediaStackHeight, layout.waveformHeight],
|
||||
[contentWidth, artSize, mediaHeight, waveform],
|
||||
`${width}x${height}, visualizer=${visualizer}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the lower-content anchor stable when the analyzer toggles', () => {
|
||||
for (const [width, height] of [
|
||||
[320, 568],
|
||||
[360, 640],
|
||||
[393, 852],
|
||||
[412, 915],
|
||||
[600, 840],
|
||||
]) {
|
||||
const hidden = getNowPlayingLayout(width, height, false);
|
||||
const visible = getNowPlayingLayout(width, height, true);
|
||||
assert.equal(visible.mediaStackHeight, hidden.mediaStackHeight);
|
||||
assert.equal(visible.mediaTopMargin, hidden.mediaTopMargin);
|
||||
assert.equal(visible.mediaBottomGap, hidden.mediaBottomGap);
|
||||
}
|
||||
});
|
||||
|
||||
test('adds the companion only to roomy tablet canvases', () => {
|
||||
for (const [width, height] of [
|
||||
[320, 568],
|
||||
[360, 640],
|
||||
[393, 852],
|
||||
[412, 915],
|
||||
[600, 840],
|
||||
[800, 600],
|
||||
]) {
|
||||
assert.equal(getTabletCompanionLayout(width, height, true), null);
|
||||
}
|
||||
|
||||
for (const [width, height] of [
|
||||
[768, 1024],
|
||||
[1024, 600],
|
||||
[1024, 768],
|
||||
[1366, 1024],
|
||||
]) {
|
||||
const layout = getTabletCompanionLayout(width, height, true);
|
||||
assert.ok(layout, `${width}x${height} should qualify`);
|
||||
assert.ok(layout.companionWidth >= 320 && layout.companionWidth <= 400);
|
||||
assert.ok(layout.playerRegionWidth > 0);
|
||||
assert.ok(layout.shellWidth <= 1200);
|
||||
assert.equal(
|
||||
layout.playerRegionWidth + layout.gap + layout.companionWidth,
|
||||
layout.shellWidth
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps calculated dimensions finite and non-negative', () => {
|
||||
for (const [width, height] of [
|
||||
[320, 568],
|
||||
[360, 640],
|
||||
[393, 852],
|
||||
[412, 915],
|
||||
[600, 840],
|
||||
[800, 600],
|
||||
[768, 1024],
|
||||
[1024, 600],
|
||||
[1024, 768],
|
||||
[1366, 1024],
|
||||
]) {
|
||||
for (const visualizer of [false, true]) {
|
||||
const layout = getNowPlayingLayout(width, height, visualizer);
|
||||
for (const value of Object.values(layout)) {
|
||||
if (typeof value !== 'number') continue;
|
||||
assert.ok(Number.isFinite(value));
|
||||
assert.ok(value >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { spacing } from '../../theme/spacing.ts';
|
||||
import { WIDE_MIN_WIDTH, isWideWindow } from '../../theme/adaptive.ts';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 408;
|
||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
||||
const MEDIA_AREA_MIN = 220;
|
||||
const TABLET_MAX_CONTENT_WIDTH = 520;
|
||||
const TABLET_ART_SIZE_MAX = 440;
|
||||
const WIDE_MAX_CONTENT_WIDTH = 960;
|
||||
export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl;
|
||||
const WIDE_RIGHT_PANE_MIN = 300;
|
||||
const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH;
|
||||
const WIDE_ART_SIZE_MAX = 400;
|
||||
const WIDE_ART_SIZE_MIN = 160;
|
||||
const WIDE_COMPACT_HEIGHT = 480;
|
||||
const VISUALIZER_WIDTH_MAX = 448;
|
||||
const VISUALIZER_SIDE_PADDING = spacing.md;
|
||||
const VISUALIZER_TOP_GAP = spacing.lg;
|
||||
const VISUALIZER_BOTTOM_GAP = spacing.sm;
|
||||
const VISUALIZER_HEIGHT_MIN = 84;
|
||||
const VISUALIZER_HEIGHT_MAX = 108;
|
||||
const VISUALIZER_HEIGHT_RATIO = 0.28;
|
||||
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;
|
||||
const MEDIA_TOP_MARGIN = spacing.lg;
|
||||
const MEDIA_BOTTOM_GAP = spacing.xl;
|
||||
const TRACK_INFO_ESTIMATE = 96;
|
||||
export const NOW_PLAYING_WAVEFORM_HEIGHT = 58;
|
||||
export const NOW_PLAYING_WAVEFORM_TOUCH_PADDING = spacing.md;
|
||||
const WAVEFORM_BLOCK_ESTIMATE =
|
||||
NOW_PLAYING_WAVEFORM_HEIGHT + NOW_PLAYING_WAVEFORM_TOUCH_PADDING * 2 + 24;
|
||||
export const NOW_PLAYING_PLAY_BUTTON_SIZE = 68;
|
||||
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
||||
export const NOW_PLAYING_SUB_BUTTON_SIZE = 40;
|
||||
const SUB_TOP_MARGIN = spacing.lg;
|
||||
const MIN_FLOATING_SPACE = spacing.sm;
|
||||
|
||||
const TABLET_SHELL_MIN_WIDTH = 720;
|
||||
const TABLET_SHELL_MAX_WIDTH = 1200;
|
||||
const TABLET_COMPANION_GAP = spacing.xl;
|
||||
const TABLET_COMPANION_MIN_WIDTH = 320;
|
||||
const TABLET_COMPANION_MAX_WIDTH = 400;
|
||||
const TABLET_STACKED_MIN_HEIGHT = 760;
|
||||
const TABLET_WIDE_PLAYER_MIN_WIDTH = 600;
|
||||
const TABLET_WIDE_MIN_HEIGHT = 520;
|
||||
|
||||
export type NowPlayingPresentation = 'standard' | 'wide';
|
||||
|
||||
export interface NowPlayingLayout {
|
||||
presentation: NowPlayingPresentation;
|
||||
isWide: boolean;
|
||||
contentPadding: number;
|
||||
contentWidth: number;
|
||||
leftPaneWidth: number;
|
||||
rightPaneWidth: number;
|
||||
controlsGap: number;
|
||||
trackInfoGap: number;
|
||||
waveformHeight: number;
|
||||
mediaStackHeight: number;
|
||||
artSize: number;
|
||||
scopeWidth: number;
|
||||
scopeHeight: number;
|
||||
visualizerTopGap: number;
|
||||
visualizerBottomGap: number;
|
||||
mediaTopMargin: number;
|
||||
mediaBottomGap: number;
|
||||
}
|
||||
|
||||
export interface TabletCompanionLayout {
|
||||
presentation: 'tablet-companion';
|
||||
shellWidth: number;
|
||||
playerRegionWidth: number;
|
||||
companionWidth: number;
|
||||
gap: number;
|
||||
playerLayout: NowPlayingLayout;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getScopeHeight(scopeWidth: number): number {
|
||||
return Math.round(
|
||||
clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing Now Playing layout calculator. Keep the numeric outputs stable for
|
||||
* phone, split-screen, foldable, and short-landscape windows.
|
||||
*/
|
||||
export function getNowPlayingLayout(
|
||||
availableWidth: number,
|
||||
availableHeight: number,
|
||||
showVisualizer: boolean,
|
||||
forceWide = false
|
||||
): NowPlayingLayout {
|
||||
const isWide = forceWide || isWideWindow(availableWidth, availableHeight);
|
||||
|
||||
if (isWide) {
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
|
||||
);
|
||||
const rightPaneWidth = Math.round(
|
||||
clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX)
|
||||
);
|
||||
const leftPaneWidth = Math.max(
|
||||
0,
|
||||
contentWidth - NOW_PLAYING_WIDE_PANE_GAP - rightPaneWidth
|
||||
);
|
||||
const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX);
|
||||
const scopeHeight = getScopeHeight(scopeWidth);
|
||||
const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
||||
const verticalBudget =
|
||||
availableHeight -
|
||||
NOW_PLAYING_CONTENT_TOP_PADDING -
|
||||
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 controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg;
|
||||
return {
|
||||
presentation: 'wide',
|
||||
isWide: true,
|
||||
contentPadding,
|
||||
contentWidth,
|
||||
leftPaneWidth,
|
||||
rightPaneWidth,
|
||||
controlsGap,
|
||||
trackInfoGap: spacing.md,
|
||||
waveformHeight: NOW_PLAYING_WAVEFORM_HEIGHT,
|
||||
mediaStackHeight: showVisualizer
|
||||
? artSize + visualizerTopGap + scopeHeight
|
||||
: artSize,
|
||||
artSize,
|
||||
scopeWidth,
|
||||
scopeHeight,
|
||||
visualizerTopGap,
|
||||
visualizerBottomGap: 0,
|
||||
mediaTopMargin: 0,
|
||||
mediaBottomGap: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
|
||||
const contentPadding =
|
||||
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - contentPadding * 2, maxContentWidth)
|
||||
);
|
||||
const scopeWidth = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX)
|
||||
);
|
||||
const scopeHeight = getScopeHeight(scopeWidth);
|
||||
const mediaMax = Math.min(
|
||||
contentWidth,
|
||||
isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth
|
||||
);
|
||||
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||
const fixedHeightBase =
|
||||
NOW_PLAYING_CONTENT_TOP_PADDING +
|
||||
NOW_PLAYING_CONTENT_BOTTOM_PADDING +
|
||||
NOW_PLAYING_HEADER_HEIGHT +
|
||||
mediaTopMargin +
|
||||
TRACK_INFO_ESTIMATE +
|
||||
WAVEFORM_BLOCK_ESTIMATE +
|
||||
TRANSPORT_TOP_MARGIN +
|
||||
NOW_PLAYING_PLAY_BUTTON_SIZE +
|
||||
SUB_TOP_MARGIN +
|
||||
NOW_PLAYING_SUB_BUTTON_SIZE +
|
||||
MIN_FLOATING_SPACE;
|
||||
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
|
||||
const scopeOffArt = Math.round(
|
||||
clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax)
|
||||
);
|
||||
const offSurplus = Math.max(0, bound - scopeOffArt);
|
||||
const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md);
|
||||
const waveformHeight = NOW_PLAYING_WAVEFORM_HEIGHT + stretchUnit * 2;
|
||||
const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP;
|
||||
const mediaStackHeight = Math.max(scopeOffArt, 96 + scopeBlockHeight);
|
||||
const scopeOnArt = mediaStackHeight - scopeBlockHeight;
|
||||
const artSize = showVisualizer ? scopeOnArt : scopeOffArt;
|
||||
const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0;
|
||||
const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0;
|
||||
|
||||
return {
|
||||
presentation: 'standard',
|
||||
isWide: false,
|
||||
contentPadding,
|
||||
contentWidth,
|
||||
leftPaneWidth: contentWidth,
|
||||
rightPaneWidth: contentWidth,
|
||||
controlsGap: TRANSPORT_TOP_MARGIN,
|
||||
trackInfoGap: spacing.md,
|
||||
waveformHeight,
|
||||
mediaStackHeight,
|
||||
artSize,
|
||||
scopeWidth,
|
||||
scopeHeight,
|
||||
visualizerTopGap,
|
||||
visualizerBottomGap,
|
||||
mediaTopMargin,
|
||||
mediaBottomGap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Additive tablet tier. Returning null means the caller must use the existing
|
||||
* single/wide layout unchanged.
|
||||
*/
|
||||
export function getTabletCompanionLayout(
|
||||
availableWidth: number,
|
||||
availableHeight: number,
|
||||
showVisualizer: boolean
|
||||
): TabletCompanionLayout | null {
|
||||
const shellWidth = Math.min(
|
||||
Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2),
|
||||
TABLET_SHELL_MAX_WIDTH
|
||||
);
|
||||
if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null;
|
||||
|
||||
const companionWidth = Math.round(
|
||||
clamp(shellWidth * 0.34, TABLET_COMPANION_MIN_WIDTH, TABLET_COMPANION_MAX_WIDTH)
|
||||
);
|
||||
const playerRegionWidth = shellWidth - TABLET_COMPANION_GAP - companionWidth;
|
||||
const canStack = availableHeight >= TABLET_STACKED_MIN_HEIGHT;
|
||||
const canUseWidePlayer =
|
||||
playerRegionWidth >= TABLET_WIDE_PLAYER_MIN_WIDTH &&
|
||||
availableHeight >= TABLET_WIDE_MIN_HEIGHT;
|
||||
if (!canStack && !canUseWidePlayer) return null;
|
||||
|
||||
const forceWide = canUseWidePlayer && availableWidth > availableHeight;
|
||||
return {
|
||||
presentation: 'tablet-companion',
|
||||
shellWidth,
|
||||
playerRegionWidth,
|
||||
companionWidth,
|
||||
gap: TABLET_COMPANION_GAP,
|
||||
playerLayout: getNowPlayingLayout(
|
||||
playerRegionWidth,
|
||||
availableHeight,
|
||||
showVisualizer,
|
||||
forceWide
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseNowPlayingCompanion } from './nowPlayingPreferences.ts';
|
||||
import { splitCollaborators } from '../../shared/library/albumGrouping.ts';
|
||||
import { buildArtistNameTokens } from '../../shared/library/artistCredits.ts';
|
||||
|
||||
test('defaults missing and invalid companion preferences to queue', () => {
|
||||
assert.equal(parseNowPlayingCompanion(null), 'queue');
|
||||
assert.equal(parseNowPlayingCompanion(''), 'queue');
|
||||
assert.equal(parseNowPlayingCompanion('spectrum'), 'queue');
|
||||
});
|
||||
|
||||
test('restores persisted queue and lyrics companion preferences', () => {
|
||||
assert.equal(parseNowPlayingCompanion('queue'), 'queue');
|
||||
assert.equal(parseNowPlayingCompanion('lyrics'), 'lyrics');
|
||||
});
|
||||
|
||||
test('builds separate clickable credits for collaborative track artists', () => {
|
||||
const artists = splitCollaborators('Dazbee feat. 9Lana & ValkyR');
|
||||
assert.deepEqual(artists, ['Dazbee', '9Lana', 'ValkyR']);
|
||||
assert.deepEqual(buildArtistNameTokens(artists), [
|
||||
{ artist: 'Dazbee', separator: ', ' },
|
||||
{ artist: '9Lana', separator: ' & ' },
|
||||
{ artist: 'ValkyR', separator: null },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export type NowPlayingCompanion = 'queue' | 'lyrics';
|
||||
|
||||
export function parseNowPlayingCompanion(value: string | null): NowPlayingCompanion {
|
||||
return value === 'lyrics' ? 'lyrics' : 'queue';
|
||||
}
|
||||
Reference in New Issue
Block a user