mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +02:00
fullscreen player tablet layout
This commit is contained in:
+3
@@ -126,6 +126,9 @@ class AstraQueueModule : Module() {
|
||||
Prop("palette") { view, values: Map<String, Any?>? ->
|
||||
view.palette = QueuePalette.from(values)
|
||||
}
|
||||
Prop("paneMode") { view, paneMode: Boolean? ->
|
||||
view.paneMode = paneMode == true
|
||||
}
|
||||
OnViewDidUpdateProps { view ->
|
||||
view.setPlaybackRequestListener { entryId, revision ->
|
||||
emitPlaybackRequest(entryId, revision)
|
||||
|
||||
+6
@@ -22,6 +22,12 @@ class AstraQueueView(
|
||||
content.palette = value
|
||||
}
|
||||
|
||||
var paneMode: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
content.paneMode = value
|
||||
}
|
||||
|
||||
init {
|
||||
addView(
|
||||
content,
|
||||
|
||||
+32
-5
@@ -3,6 +3,7 @@ package expo.modules.astralibraryscanner.queue
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Typeface
|
||||
@@ -106,6 +107,21 @@ class QueueContentView(
|
||||
applyPalette()
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded as the now-playing companion pane. The pane is opened from a
|
||||
* button that already names it, so the title and count are a second copy of
|
||||
* something the screen says elsewhere. Edit is a function rather than a
|
||||
* label, so it stays and simply right-aligns in the row the labels leave.
|
||||
*/
|
||||
var paneMode: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
val labelVisibility = if (value) GONE else VISIBLE
|
||||
titleView.visibility = labelVisibility
|
||||
countView.visibility = labelVisibility
|
||||
applyPalette()
|
||||
}
|
||||
|
||||
var active: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -559,10 +575,14 @@ class QueueContentView(
|
||||
}
|
||||
|
||||
private fun applyPalette() {
|
||||
background = if (sheetMode) {
|
||||
topRounded(palette.background, 16f)
|
||||
} else {
|
||||
ColorDrawable(palette.background)
|
||||
background = when {
|
||||
sheetMode -> topRounded(palette.background, 16f)
|
||||
// The companion pane is a region of the player's own surface, not a panel
|
||||
// on top of it. Painting `background` here drew a second slab inside the
|
||||
// column — a bordered card floating in the middle of the screen, while
|
||||
// the lyrics companion beside it had none.
|
||||
paneMode -> null
|
||||
else -> ColorDrawable(palette.background)
|
||||
}
|
||||
sheetHandle.background = rounded(palette.divider, 999f)
|
||||
titleView.setTextColor(palette.text)
|
||||
@@ -930,7 +950,14 @@ class QueueContentView(
|
||||
row.artwork.visibility = if (editing) GONE else VISIBLE
|
||||
row.handle.visibility = if (editing) GONE else VISIBLE
|
||||
row.setSurfaceColor(
|
||||
if (item.entryId in selected) palette.selectedSurface else palette.surface,
|
||||
when {
|
||||
item.entryId in selected -> palette.selectedSurface
|
||||
// Rows paint their own surface so a sheet reads as a stack of
|
||||
// cards. In the pane that surface is a second slab per row on top
|
||||
// of the player's background — the pane is the player's surface.
|
||||
this@QueueContentView.paneMode -> Color.TRANSPARENT
|
||||
else -> palette.surface
|
||||
},
|
||||
)
|
||||
loadArtwork(row.artwork, item.artworkThumbPath)
|
||||
row.setOnClickListener { onRowClick?.invoke(item) }
|
||||
|
||||
@@ -70,6 +70,14 @@ declare class AstraQueueModuleType extends NativeModule<AstraQueueEvents> {
|
||||
export interface AstraQueueViewProps extends ViewProps {
|
||||
active: boolean;
|
||||
palette: NativeQueuePalette;
|
||||
/**
|
||||
* Embedded as the now-playing companion pane rather than presented as a
|
||||
* sheet. Drops the "Queue" title and its count — the pane is reached from a
|
||||
* button that already names it, and the player header a few hundred dp to the
|
||||
* left already says what is playing from where. Edit stays: it is a function,
|
||||
* not a label.
|
||||
*/
|
||||
paneMode?: boolean;
|
||||
}
|
||||
|
||||
function nativeColor(value: string): number {
|
||||
|
||||
@@ -27,6 +27,32 @@ import type { Track } from '@/types/audio';
|
||||
|
||||
const ANCHOR_RATIO = 0.4;
|
||||
const H_PADDING = 22;
|
||||
|
||||
/**
|
||||
* Type sizing per surface.
|
||||
*
|
||||
* The phone body scales its type with its column, which is fine when the column
|
||||
* is the whole screen — there is no other use for the width.
|
||||
*
|
||||
* A pane must not do that. Scaling type with width means widening the pane
|
||||
* spends the new space on bigger glyphs and every line still breaks in the same
|
||||
* place: going 504 → 576dp took the type 29pt → 32pt and changed nothing about
|
||||
* the wrapping. So the pane declares its size and lets width buy *characters*,
|
||||
* which is the only thing that actually stops a line breaking mid-phrase.
|
||||
*/
|
||||
interface LyricsSurfaceSpec {
|
||||
/** Declared point size; `null` scales with the column instead. */
|
||||
fixedSize: number | null;
|
||||
/** Ceiling for the scaled path. */
|
||||
maxSize: number;
|
||||
hPadding: number;
|
||||
}
|
||||
const SURFACE: Record<'band' | 'panel', LyricsSurfaceSpec> = {
|
||||
band: { fixedSize: null, maxSize: 24, hPadding: H_PADDING },
|
||||
panel: { fixedSize: 26, maxSize: 26, hPadding: 28 },
|
||||
};
|
||||
|
||||
export type LyricsSurface = keyof typeof SURFACE;
|
||||
// The displayed active line lags the audio by a fixed pipeline delay (RNTP
|
||||
// position reporting + poll/smoothing) that the desktop doesn't have, so advance
|
||||
// the lyrics clock by this much. Tune to taste — bigger = earlier highlight.
|
||||
@@ -37,13 +63,23 @@ interface LyricsBandProps {
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
onSeek: (seconds: number) => void;
|
||||
/** `panel` is the tablet companion column; `band` is the phone body. */
|
||||
surface?: LyricsSurface;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: LyricsBandProps) {
|
||||
export function LyricsBand({
|
||||
track,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
onSeek,
|
||||
surface = 'band',
|
||||
}: LyricsBandProps) {
|
||||
const { fixedSize, maxSize, hPadding } = SURFACE[surface];
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const entry = useLyricsStore((s) => s.byPath[track.path]);
|
||||
@@ -84,7 +120,8 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
|
||||
// Uniform size for every line (LyricsLine no longer scales font per tier), so pick
|
||||
// a comfortable reading size rather than the old oversized active value.
|
||||
const baseSize = size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, 24)) : 22;
|
||||
const baseSize =
|
||||
fixedSize ?? (size.w > 0 ? Math.round(clamp(size.w * 0.058, 18, maxSize)) : 22);
|
||||
|
||||
// --- auto-scroll centering ---
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
@@ -146,7 +183,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
return (
|
||||
<View style={{ flex: 1 }} onLayout={onContainerLayout}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: H_PADDING }}
|
||||
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: hPadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}>
|
||||
@@ -178,7 +215,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
onScrollBeginDrag={() => setFollowPaused(true)}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120,
|
||||
paddingHorizontal: H_PADDING,
|
||||
paddingHorizontal: hPadding,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
import { LyricsBand } from '@/components/lyrics/LyricsBand';
|
||||
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||
import { seekTo } from '@/audio/playbackController';
|
||||
import { spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import {
|
||||
getNowPlayingTrackTransitionKey,
|
||||
@@ -11,18 +9,12 @@ import {
|
||||
} from './nowPlayingTrackTransition';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import type { NowPlayingCompanion } from './nowPlayingPreferences';
|
||||
import type { Track } from '@/types/audio';
|
||||
import {
|
||||
AstraQueueView,
|
||||
toNativeQueuePalette,
|
||||
} from '../../../modules/astra-library-scanner';
|
||||
|
||||
const COMPANION_SEGMENTS = [
|
||||
{ key: 'queue', label: 'Queue' },
|
||||
{ key: 'lyrics', label: 'Lyrics' },
|
||||
];
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
interface NowPlayingCompanionPaneProps {
|
||||
@@ -40,7 +32,6 @@ export function NowPlayingCompanionPane({
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
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(
|
||||
@@ -51,49 +42,36 @@ export function NowPlayingCompanionPane({
|
||||
track?.path ?? null
|
||||
);
|
||||
|
||||
const selectCompanion = (next: string) => {
|
||||
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
|
||||
if (value === companion) return;
|
||||
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 style={styles.content}>
|
||||
{companion === 'queue' ? (
|
||||
<AstraQueueView
|
||||
active={active}
|
||||
paneMode
|
||||
palette={toNativeQueuePalette(colors)}
|
||||
style={styles.nativeQueue}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
{companion === 'queue' ? (
|
||||
<AstraQueueView
|
||||
active={active}
|
||||
palette={toNativeQueuePalette(colors)}
|
||||
style={styles.nativeQueue}
|
||||
) : track ? (
|
||||
<NowPlayingTrackFadeThrough
|
||||
transitionKey={transitionTrackKey}
|
||||
style={styles.lyricsFrame}
|
||||
contentStyle={StyleSheet.absoluteFill}
|
||||
>
|
||||
<LyricsBand
|
||||
track={track}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
surface="panel"
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
) : track ? (
|
||||
<NowPlayingTrackFadeThrough
|
||||
transitionKey={transitionTrackKey}
|
||||
style={styles.lyricsFrame}
|
||||
contentStyle={StyleSheet.absoluteFill}
|
||||
>
|
||||
<LyricsBand
|
||||
track={track}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
onSeek={(seconds) => void seekTo(seconds)}
|
||||
/>
|
||||
</NowPlayingTrackFadeThrough>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
</NowPlayingTrackFadeThrough>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -101,17 +79,13 @@ export function NowPlayingCompanionPane({
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
root: {
|
||||
// No border and no card: the pane is a region of the same surface as the
|
||||
// player, separated by the gap the layout already reserves. A rule down the
|
||||
// middle plus a bordered slab was two frames around one thing.
|
||||
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,
|
||||
|
||||
@@ -39,6 +39,7 @@ 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 type { NowPlayingCompanion } from '@/components/player/nowPlayingPreferences';
|
||||
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||
import {
|
||||
@@ -236,6 +237,8 @@ export function NowPlayingOverlay({
|
||||
const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible);
|
||||
const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion);
|
||||
const setNowPlayingCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
|
||||
const companionOpen = useSettingsStore((s) => s.nowPlayingCompanionOpen);
|
||||
const setCompanionOpen = useSettingsStore((s) => s.setNowPlayingCompanionOpen);
|
||||
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const libraryTracks = useLibraryStore((s) => s.tracks);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
@@ -332,13 +335,21 @@ export function NowPlayingOverlay({
|
||||
false,
|
||||
fontScale
|
||||
);
|
||||
const tabletCompanionLayout = getTabletCompanionLayout(
|
||||
// Two separate facts, the same split the shell makes for the dock: whether
|
||||
// this window *could* seat a pane beside the player, and whether the pane is
|
||||
// actually out. The default tablet player is the full-width composition with
|
||||
// nothing docked — the queue and lyrics buttons open the pane, exactly as they
|
||||
// open a sheet and a takeover on a phone.
|
||||
const companionFit = getTabletCompanionLayout(
|
||||
effectiveWidth,
|
||||
availableHeight,
|
||||
layoutScopeVisible,
|
||||
fontScale
|
||||
fontScale,
|
||||
nowPlayingCompanion
|
||||
);
|
||||
const hasTabletCompanion = tabletCompanionLayout !== null;
|
||||
const companionCapable = companionFit !== null && !dock;
|
||||
const hasTabletCompanion = companionCapable && companionOpen;
|
||||
const tabletCompanionLayout = hasTabletCompanion ? companionFit : null;
|
||||
const layout = tabletCompanionLayout?.playerLayout ?? standardLayout;
|
||||
const deck = layout.deck;
|
||||
// The density tier owns whether there is room for the lyric row, and it is
|
||||
@@ -349,8 +360,11 @@ export function NowPlayingOverlay({
|
||||
const shellWidth = tabletCompanionLayout?.shellWidth ?? layout.contentWidth;
|
||||
// Lyrics takes over only on the phone. Roomy tablets keep the player visible
|
||||
// and render lyrics in the companion rail.
|
||||
// Gated on *capable*, not open: a window that can seat the pane never shows
|
||||
// the phone's full-body lyrics takeover, even while the pane is closed. Lyrics
|
||||
// there is a pane, and `lyricsVisible` only pre-selects which tab it opens on.
|
||||
const lyricsMode =
|
||||
!hasTabletCompanion && !isDesktopTarget && !!track && lyricsVisible;
|
||||
!companionCapable && !isDesktopTarget && !!track && lyricsVisible;
|
||||
const source = activePresentation.sourceLabel;
|
||||
const shellRight =
|
||||
insets.right +
|
||||
@@ -414,30 +428,45 @@ export function NowPlayingOverlay({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTabletCompanion || isDesktopTarget) return;
|
||||
if (!companionCapable || isDesktopTarget) return;
|
||||
// A queue sheet reached by some other path can't coexist with the pane, so
|
||||
// fold it in — that one *does* open the pane, because the user asked for the
|
||||
// queue. A stale `lyricsVisible` only picks the tab; it must not force the
|
||||
// pane out, or the default composition would never be what you see first.
|
||||
if (queueOpen) {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setQueueOpen(false);
|
||||
void setNowPlayingCompanion('queue');
|
||||
void setCompanionOpen(true);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
if (lyricsVisible) void setNowPlayingCompanion('lyrics');
|
||||
return undefined;
|
||||
}, [
|
||||
companionCapable,
|
||||
isDesktopTarget,
|
||||
lyricsVisible,
|
||||
queueOpen,
|
||||
setCompanionOpen,
|
||||
setNowPlayingCompanion,
|
||||
hasTabletCompanion,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Open the pane on `which`, or close it if that is already what it is showing.
|
||||
* The buttons are the pane's only trigger, so each has to be able to undo
|
||||
* itself the way the phone's sheet and takeover do.
|
||||
*/
|
||||
const toggleCompanion = (which: NowPlayingCompanion) => {
|
||||
const alreadyShowing = companionOpen && nowPlayingCompanion === which;
|
||||
void setNowPlayingCompanion(which);
|
||||
void setCompanionOpen(!alreadyShowing);
|
||||
};
|
||||
|
||||
const showQueue = () => {
|
||||
if (hasTabletCompanion) {
|
||||
if (!isDesktopTarget) {
|
||||
void setLyricsVisible(false);
|
||||
void setNowPlayingCompanion('queue');
|
||||
}
|
||||
if (companionCapable) {
|
||||
if (!isDesktopTarget) void setLyricsVisible(false);
|
||||
toggleCompanion('queue');
|
||||
return;
|
||||
}
|
||||
suspendPanForChildTransition();
|
||||
@@ -558,8 +587,8 @@ export function NowPlayingOverlay({
|
||||
};
|
||||
|
||||
const showLyrics = () => {
|
||||
if (hasTabletCompanion) {
|
||||
void setNowPlayingCompanion('lyrics');
|
||||
if (companionCapable) {
|
||||
toggleCompanion('lyrics');
|
||||
return;
|
||||
}
|
||||
setPhoneLyricsVisible(!lyricsVisible);
|
||||
@@ -1791,11 +1820,13 @@ export function NowPlayingOverlay({
|
||||
haptic="selection"
|
||||
onPress={showLyrics}
|
||||
accessibilityLabel={
|
||||
hasTabletCompanion
|
||||
? 'Show lyrics in companion'
|
||||
: lyricsVisible
|
||||
? 'Hide lyrics'
|
||||
: 'Show lyrics'
|
||||
(
|
||||
companionCapable
|
||||
? hasTabletCompanion && nowPlayingCompanion === 'lyrics'
|
||||
: lyricsVisible
|
||||
)
|
||||
? 'Hide lyrics'
|
||||
: 'Show lyrics'
|
||||
}
|
||||
accessibilityState={{
|
||||
selected: hasTabletCompanion
|
||||
|
||||
@@ -319,16 +319,125 @@ test('caps reserved line boxes so a huge font setting cannot run away', () => {
|
||||
assert.ok(capped.deck.height > layoutFor(device, 1, true).deck.height);
|
||||
});
|
||||
|
||||
/**
|
||||
* Windows that must use the side-by-side row: too short to stack, whatever
|
||||
* their width. Tablets used to be in here because the branch was picked by
|
||||
* `isWideWindow` — see `STACKED_TABLETS`.
|
||||
*/
|
||||
const LANDSCAPE = [
|
||||
{ name: 'Pixel 7 Pro landscape', width: 891, height: 339 },
|
||||
{ name: 'S22 landscape', width: 780, height: 312 },
|
||||
{ name: 'Poco M5 landscape', width: 873, height: 345 },
|
||||
{ name: 'S25 Ultra landscape', width: 918, height: 363 },
|
||||
{ name: 'Tablet 12" landscape', width: 1366, height: 1000 },
|
||||
{ name: 'Foldable open landscape', width: 800, height: 650 },
|
||||
{ name: 'very short landscape', width: 800, height: 300 },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Windows with the height to stack artwork over a deck. Side-by-side is the
|
||||
* phone-in-landscape compromise, not the big-screen layout, so none of these
|
||||
* may take it — including a tablet in landscape.
|
||||
*/
|
||||
const STACKED_TABLETS = [
|
||||
{ name: 'Tablet 10" landscape', width: 1248, height: 752 },
|
||||
{ name: 'Tablet 12" landscape', width: 1366, height: 1000 },
|
||||
{ name: 'Tablet portrait', width: 768, height: 1150 },
|
||||
{ name: 'Foldable open landscape', width: 800, height: 650 },
|
||||
{ name: 'Foldable open portrait', width: 808, height: 868 },
|
||||
{ name: 'Tablet 10" landscape, companion out', width: 856, height: 752 },
|
||||
] as const;
|
||||
|
||||
test('a window with the height to stack never uses the side-by-side row', () => {
|
||||
for (const fontScale of FONT_SCALES) {
|
||||
for (const window of STACKED_TABLETS) {
|
||||
for (const scope of [false, true]) {
|
||||
// `forceWide` is the companion tier asking for the landscape row. Even
|
||||
// that must lose: the player cannot change shape because a pane slid in
|
||||
// beside it.
|
||||
for (const forceWide of [false, true]) {
|
||||
const layout = getNowPlayingLayout(
|
||||
window.width,
|
||||
window.height,
|
||||
scope,
|
||||
forceWide,
|
||||
fontScale
|
||||
);
|
||||
assert.equal(
|
||||
layout.presentation,
|
||||
'standard',
|
||||
`${window.name} (scope ${scope}, forceWide ${forceWide}) should stack`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a stacked tablet gives the artwork the height a phone cannot', () => {
|
||||
for (const window of STACKED_TABLETS) {
|
||||
const layout = getNowPlayingLayout(window.width, window.height, false, false, 1);
|
||||
assert.ok(
|
||||
layout.artSizeScopeOff >= 320,
|
||||
`${window.name}: art ${layout.artSizeScopeOff} is a thumbnail on this screen`
|
||||
);
|
||||
// Height-bound, not ceiling-bound: the artwork is square, so it can only
|
||||
// ever spend height, and it must still clear the deck.
|
||||
assert.ok(
|
||||
layout.artSizeScopeOff <= layout.stageHeight,
|
||||
`${window.name}: art ${layout.artSizeScopeOff} overflows stage ${layout.stageHeight}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stacked tablet spends spare width on the deck, not on the artwork', () => {
|
||||
// The waveform is the only control that turns width into resolution. The
|
||||
// artwork is square and gains nothing, so a wider column must not inflate it.
|
||||
// Both windows are below the deck's ceiling so the deck is still growing.
|
||||
const wider = getNowPlayingLayout(700, 1000, false, false, 1);
|
||||
const narrower = getNowPlayingLayout(620, 1000, false, false, 1);
|
||||
assert.ok(wider.contentWidth > narrower.contentWidth);
|
||||
assert.equal(wider.artSizeScopeOff, narrower.artSizeScopeOff);
|
||||
});
|
||||
|
||||
test('the deck stops widening well before it fills a tablet', () => {
|
||||
// Past about half a 10" tablet the extra width stops buying a better scrub
|
||||
// and starts stretching the rows around it — title hard left, favourite hard
|
||||
// right, void between. Same failure as a full-width `TrackRow`.
|
||||
for (const window of STACKED_TABLETS) {
|
||||
const layout = getNowPlayingLayout(window.width, window.height, false, false, 1);
|
||||
assert.ok(
|
||||
layout.contentWidth <= 640,
|
||||
`${window.name}: deck ${layout.contentWidth} exceeds the ceiling`
|
||||
);
|
||||
// A portrait tablet *should* fill its column — there is no surplus to
|
||||
// leave. The margin only has to appear where the window is genuinely wide.
|
||||
if (window.width < 1000) continue;
|
||||
assert.ok(
|
||||
layout.contentWidth <= window.width * 0.6,
|
||||
`${window.name}: deck ${layout.contentWidth} of ${window.width} is a stretched row`
|
||||
);
|
||||
}
|
||||
// And the ceiling actually binds on a tablet, rather than the window doing it.
|
||||
assert.equal(getNowPlayingLayout(1248, 752, false, false, 1).contentWidth, 640);
|
||||
assert.equal(getNowPlayingLayout(1366, 1000, false, false, 1).contentWidth, 640);
|
||||
});
|
||||
|
||||
test('a phone is untouched by every tablet ceiling and floor', () => {
|
||||
const phones = [...LANDSCAPE, { name: 'Pixel 7 Pro portrait', width: 380, height: 850 }];
|
||||
for (const window of phones) {
|
||||
for (const scope of [false, true]) {
|
||||
const layout = getNowPlayingLayout(window.width, window.height, scope, false, 1);
|
||||
assert.ok(
|
||||
layout.artSize <= 400,
|
||||
`${window.name}: art ${layout.artSize} exceeds the phone ceiling`
|
||||
);
|
||||
assert.ok(
|
||||
layout.contentWidth <= 960,
|
||||
`${window.name}: row ${layout.contentWidth} exceeds the phone ceiling`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('landscape sizes its panes from their contents, not a fixed split', () => {
|
||||
for (const fontScale of FONT_SCALES) {
|
||||
for (const window of LANDSCAPE) {
|
||||
@@ -435,6 +544,61 @@ test('landscape never grows the artwork when the scope comes on', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('gives lyrics the majority of the shell and the queue a sidecar share', () => {
|
||||
for (const [width, height] of [
|
||||
[1248, 752],
|
||||
[1366, 1000],
|
||||
]) {
|
||||
const queue = getTabletCompanionLayout(width, height, false, 1, 'queue');
|
||||
const lyrics = getTabletCompanionLayout(width, height, false, 1, 'lyrics');
|
||||
assert.ok(queue && lyrics, `${width}x${height} should qualify`);
|
||||
// A queue row is a thumbnail and two short lines; a lyric line is a
|
||||
// sentence. Sizing both the same is what left lyrics wrapping mid-phrase.
|
||||
assert.ok(
|
||||
queue.companionWidth / queue.shellWidth <= 0.4,
|
||||
`queue took ${queue.companionWidth} of ${queue.shellWidth}`
|
||||
);
|
||||
assert.ok(
|
||||
lyrics.companionWidth / lyrics.shellWidth >= 0.55,
|
||||
`lyrics took only ${lyrics.companionWidth} of ${lyrics.shellWidth}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('never lets a companion starve the player, however wide it wants to be', () => {
|
||||
for (let width = 720; width <= 2000; width += 1) {
|
||||
for (const companion of ['queue', 'lyrics'] as const) {
|
||||
const layout = getTabletCompanionLayout(width, 900, false, 1, companion);
|
||||
if (!layout) continue;
|
||||
assert.ok(
|
||||
layout.playerRegionWidth >= 320,
|
||||
`${companion} at ${width} left the player ${layout.playerRegionWidth}`
|
||||
);
|
||||
assert.equal(
|
||||
layout.playerRegionWidth + layout.gap + layout.companionWidth,
|
||||
layout.shellWidth,
|
||||
`${companion} at ${width} does not account for the shell`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a companion never changes the artwork it sits beside', () => {
|
||||
// The pane takes width from the deck, not from the cover: the artwork is
|
||||
// height-bound, so opening or widening a companion must not shrink it. This
|
||||
// is also what lets the pane animate in as a translate rather than a resize.
|
||||
const closed = getNowPlayingLayout(1248, 752, false, false, 1, true);
|
||||
for (const companion of ['queue', 'lyrics'] as const) {
|
||||
const open = getTabletCompanionLayout(1248, 752, false, 1, companion);
|
||||
assert.ok(open, `${companion} should qualify`);
|
||||
assert.equal(
|
||||
open.playerLayout.artSizeScopeOff,
|
||||
closed.artSizeScopeOff,
|
||||
`${companion} resized the artwork`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('adds the companion only to roomy tablet canvases', () => {
|
||||
for (const device of DEVICES) {
|
||||
assert.equal(getTabletCompanionLayout(device.width, device.height, true), null);
|
||||
@@ -442,13 +606,17 @@ test('adds the companion only to roomy tablet canvases', () => {
|
||||
for (const [width, height] of [
|
||||
[600, 840],
|
||||
[800, 600],
|
||||
// A 600dp-tall tablet cannot stack artwork over a deck, and the
|
||||
// side-by-side player it used to fall back to is the phone-in-landscape
|
||||
// compromise rather than a tablet layout. With nothing good to show beside
|
||||
// the player, it gets the full window instead of a companion.
|
||||
[1024, 600],
|
||||
]) {
|
||||
assert.equal(getTabletCompanionLayout(width, height, true), null);
|
||||
}
|
||||
|
||||
for (const [width, height] of [
|
||||
[768, 1024],
|
||||
[1024, 600],
|
||||
[1024, 768],
|
||||
[1366, 1024],
|
||||
]) {
|
||||
|
||||
@@ -22,7 +22,37 @@ const MAX_CONTENT_WIDTH = 408;
|
||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||
const TABLET_MAX_CONTENT_WIDTH = 520;
|
||||
const TABLET_ART_SIZE_MAX = 440;
|
||||
const WIDE_MAX_CONTENT_WIDTH = 960;
|
||||
|
||||
/**
|
||||
* A window this size stacks — artwork over a deck — rather than putting the
|
||||
* artwork beside the controls.
|
||||
*
|
||||
* Side-by-side exists for one reason: a phone in landscape is ~340dp tall and
|
||||
* cannot stack. It is not a "big screen" layout, and routing tablets into it via
|
||||
* `isWideWindow` is what made a 10" tablet read as an enlarged landscape phone.
|
||||
* The question is the same one the navigation rail and the EQ both ask — is
|
||||
* there height to stack — not whether the window happens to be landscape.
|
||||
*/
|
||||
const TABLET_STACK_MIN_WIDTH = 600;
|
||||
const TABLET_STACK_MIN_HEIGHT = 620;
|
||||
|
||||
/**
|
||||
* Stacked-tablet ceilings.
|
||||
*
|
||||
* The deck runs wider than a phone's — the waveform is the one control that
|
||||
* turns width into resolution — but only so far. Past roughly half a 10" tablet
|
||||
* the extra width stops buying a better scrub and starts stretching the rows
|
||||
* around it: the title at the far left and the favourite toggle at the far
|
||||
* right with a void between them, the same failure as a full-width `TrackRow`.
|
||||
* At this cap the deck also lands at the scope rail's width, so the two read as
|
||||
* one column under the artwork rather than two different measures.
|
||||
*
|
||||
* The artwork keeps a ceiling of its own: it is square, so it can only spend
|
||||
* height, and past this it stops being artwork and starts being a wall.
|
||||
*/
|
||||
const TABLET_STACK_MAX_CONTENT_WIDTH = 640;
|
||||
const TABLET_STACK_ART_SIZE_MAX = 560;
|
||||
const TABLET_STACK_SCOPE_WIDTH_MAX = 640;
|
||||
export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl;
|
||||
const WIDE_RIGHT_PANE_MIN = 300;
|
||||
/**
|
||||
@@ -33,8 +63,45 @@ const WIDE_RIGHT_PANE_MIN = 300;
|
||||
* simply unused.
|
||||
*/
|
||||
const WIDE_RIGHT_PANE_MAX = 560;
|
||||
const WIDE_ART_SIZE_MAX = 400;
|
||||
const WIDE_ART_SIZE_MIN = 160;
|
||||
|
||||
/**
|
||||
* Ceilings for the landscape row. Two declared sets, not one scaled number.
|
||||
*
|
||||
* The originals were tuned against a phone in landscape — ~411dp tall — where
|
||||
* they never actually bind, because the artwork runs out of *height* long before
|
||||
* it reaches 400dp. Reusing them on a tablet is what left a 10" screen with
|
||||
* 400dp artwork, a 960dp row inside a 1248dp window, and ~250dp of dead space
|
||||
* under the deck: the caps were doing nothing on the device they were written
|
||||
* for and everything on the device they weren't.
|
||||
*
|
||||
* The tablet set is what lets the artwork actually be the subject of the screen.
|
||||
* A window has to clear both a height and a width bar to get it — height because
|
||||
* that is what the artwork is bound by, width because a tall narrow window has
|
||||
* nowhere to put the deck.
|
||||
*/
|
||||
interface WideRowCaps {
|
||||
artMax: number;
|
||||
rowMax: number;
|
||||
/**
|
||||
* The scope strip's ceiling moves with the artwork's, or raising one alone
|
||||
* inverts them: at 640dp of art against the phone's 448dp strip cap, the
|
||||
* "rail under the artwork" becomes a box narrower than what it sits under.
|
||||
* The tablet number is set to leave the deck ~400dp once the stage has taken
|
||||
* its share, rather than to any ratio.
|
||||
*/
|
||||
scopeMax: number;
|
||||
}
|
||||
const WIDE_CAPS_PHONE: WideRowCaps = { artMax: 400, rowMax: 960, scopeMax: 448 };
|
||||
const WIDE_CAPS_TABLET: WideRowCaps = { artMax: 640, rowMax: 1160, scopeMax: 720 };
|
||||
const WIDE_TABLET_MIN_HEIGHT = 640;
|
||||
const WIDE_TABLET_MIN_WIDTH = 900;
|
||||
|
||||
function wideRowCaps(availableWidth: number, availableHeight: number): WideRowCaps {
|
||||
return availableHeight >= WIDE_TABLET_MIN_HEIGHT && availableWidth >= WIDE_TABLET_MIN_WIDTH
|
||||
? WIDE_CAPS_TABLET
|
||||
: WIDE_CAPS_PHONE;
|
||||
}
|
||||
/**
|
||||
* How much wider than the artwork the scope strip runs. Mirrors the portrait
|
||||
* proportion (~1.5x), where the strip reads as a rail under the art rather than
|
||||
@@ -66,6 +133,17 @@ export const NOW_PLAYING_SUB_BUTTON_SIZE = 40;
|
||||
* rather than dropping a tier to buy artwork it doesn't need.
|
||||
*/
|
||||
const ART_COMFORT_MIN = 152;
|
||||
/**
|
||||
* The same floor for a stacked tablet, where 152dp of artwork is not "small",
|
||||
* it is a thumbnail on a 10" screen.
|
||||
*
|
||||
* This is what stops a short, wide tablet from spending its column on the
|
||||
* richest deck: at 752dp of height the spacious deck takes 360 of it and leaves
|
||||
* the artwork under 300. Raising the bar makes the ladder step down to a leaner
|
||||
* deck and hand the difference to the artwork — which is the whole point of
|
||||
* stacking on a device this size.
|
||||
*/
|
||||
const TABLET_ART_COMFORT_MIN = 320;
|
||||
/**
|
||||
* Artwork the scope rail must leave behind to be worth its stage space. Below
|
||||
* this the rail is dropped rather than squeezing the art into a thumbnail.
|
||||
@@ -75,11 +153,32 @@ const SCOPE_RAIL_MIN_ART = 96;
|
||||
const TABLET_SHELL_MIN_WIDTH = 720;
|
||||
const TABLET_SHELL_MAX_WIDTH = 1200;
|
||||
const TABLET_COMPANION_GAP = spacing.xl;
|
||||
/**
|
||||
* Companion widths, per companion.
|
||||
*
|
||||
* A queue row is a thumbnail plus two short lines and reads fine at 360dp. A
|
||||
* lyric line is a sentence, and at that width it breaks mid-phrase — the most
|
||||
* visible flaw in the panel. Lyrics therefore get a wider column; it is the
|
||||
* cheapest readability win available and costs the player nothing it was using.
|
||||
*/
|
||||
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;
|
||||
const TABLET_COMPANION_LYRICS_MIN_WIDTH = 440;
|
||||
const TABLET_COMPANION_LYRICS_MAX_WIDTH = 760;
|
||||
const TABLET_COMPANION_WIDTH_RATIO = 0.34;
|
||||
/**
|
||||
* Lyrics take the majority of the shell, not a sidecar's share. A queue row is
|
||||
* a thumbnail and two short lines; a lyric line is a sentence, and at half the
|
||||
* screen it is still breaking mid-phrase.
|
||||
*/
|
||||
const TABLET_COMPANION_LYRICS_WIDTH_RATIO = 0.6;
|
||||
/**
|
||||
* The player never gives up more than this, however much the companion wants.
|
||||
* It is what keeps the artwork the subject on a narrow shell — an unfolded
|
||||
* foldable would otherwise hand the lyrics 60% of 776dp and leave the player a
|
||||
* 280dp strip.
|
||||
*/
|
||||
const TABLET_PLAYER_REGION_MIN = 420;
|
||||
|
||||
export type NowPlayingPresentation = 'standard' | 'wide';
|
||||
export type NowPlayingDensity = 'spacious' | 'regular' | 'compact';
|
||||
@@ -328,9 +427,28 @@ export function getNowPlayingLayout(
|
||||
availableHeight: number,
|
||||
showVisualizer: boolean,
|
||||
forceWide = false,
|
||||
fontScale = 1
|
||||
fontScale = 1,
|
||||
/**
|
||||
* Treat this as a tablet column regardless of how narrow it is.
|
||||
*
|
||||
* The companion tier passes it, because *it* knows the window is a tablet
|
||||
* even when the pane has squeezed the player into 450dp. Inferring
|
||||
* tablet-ness from the region's own width would drop those columns back to
|
||||
* phone ceilings and the phone comfort floor the moment the lyrics pane got
|
||||
* wide — the artwork would shrink because the pane grew, which is backwards.
|
||||
* It cannot be inferred by lowering `TABLET_STACK_MIN_WIDTH` either: large
|
||||
* phones are 430-450dp wide in portrait and would be caught by it.
|
||||
*/
|
||||
forceTabletStack = false
|
||||
): NowPlayingLayout {
|
||||
const isWide = forceWide || isWideWindow(availableWidth, availableHeight);
|
||||
// Stacking wins wherever it fits, including over `forceWide` — a tablet with
|
||||
// the companion pane out still has the height to stack, and the player must
|
||||
// not change shape just because a pane slid in beside it.
|
||||
const stacksAsTablet =
|
||||
forceTabletStack ||
|
||||
(availableWidth >= TABLET_STACK_MIN_WIDTH && availableHeight >= TABLET_STACK_MIN_HEIGHT);
|
||||
const isWide =
|
||||
!stacksAsTablet && (forceWide || isWideWindow(availableWidth, availableHeight));
|
||||
|
||||
const columnHeight =
|
||||
availableHeight -
|
||||
@@ -340,9 +458,10 @@ export function getNowPlayingLayout(
|
||||
|
||||
if (isWide) {
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
const caps = wideRowCaps(availableWidth, availableHeight);
|
||||
const rowSpace = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
|
||||
Math.min(availableWidth - contentPadding * 2, caps.rowMax)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -367,7 +486,7 @@ export function getNowPlayingLayout(
|
||||
);
|
||||
const inner = Math.max(0, columnHeight - tier.stageInset * 2);
|
||||
const fitArt = (space: number) =>
|
||||
Math.round(Math.max(0, Math.min(space, stageSpace, WIDE_ART_SIZE_MAX)));
|
||||
Math.round(Math.max(0, Math.min(space, stageSpace, caps.artMax)));
|
||||
|
||||
// The strip's height follows its width, which follows the artwork, which
|
||||
// depends on the strip's height. Seed with the tallest strip it could be
|
||||
@@ -384,7 +503,7 @@ export function getNowPlayingLayout(
|
||||
clamp(
|
||||
artScopeOn * WIDE_SCOPE_WIDTH_RATIO,
|
||||
artScopeOn,
|
||||
Math.min(stageSpace, VISUALIZER_WIDTH_MAX)
|
||||
Math.min(stageSpace, caps.scopeMax)
|
||||
)
|
||||
);
|
||||
scopeHeight = getScopeHeight(scopeWidth);
|
||||
@@ -456,19 +575,37 @@ export function getNowPlayingLayout(
|
||||
|
||||
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
|
||||
const maxContentWidth = stacksAsTablet
|
||||
? TABLET_STACK_MAX_CONTENT_WIDTH
|
||||
: 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)
|
||||
Math.min(
|
||||
availableWidth - VISUALIZER_SIDE_PADDING * 2,
|
||||
stacksAsTablet ? TABLET_STACK_SCOPE_WIDTH_MAX : VISUALIZER_WIDTH_MAX
|
||||
)
|
||||
);
|
||||
const scopeHeight = getScopeHeight(scopeWidth);
|
||||
|
||||
// The artwork is capped separately from the deck on purpose. It is square, so
|
||||
// a wider column buys it nothing — letting `contentWidth` size it is what
|
||||
// would turn a tablet's extra width into a wall of cover art instead of a
|
||||
// longer seek bar.
|
||||
const artWidthCap = (tier: DensityTier) =>
|
||||
Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : tier.artMax);
|
||||
Math.min(
|
||||
contentWidth,
|
||||
stacksAsTablet
|
||||
? TABLET_STACK_ART_SIZE_MAX
|
||||
: isTabletColumn
|
||||
? TABLET_ART_SIZE_MAX
|
||||
: tier.artMax
|
||||
);
|
||||
|
||||
// Walk richest to leanest and take the first tier whose artwork lands in a
|
||||
// comfortable band. Deliberately measured against the scope-ON size at every
|
||||
@@ -483,7 +620,7 @@ export function getNowPlayingLayout(
|
||||
columnHeight - candidateDeck.height - candidate.stageInset * 2;
|
||||
const scopeBlock = candidate.scopeTopGap + scopeHeight + candidate.scopeBottomGap;
|
||||
const art = Math.min(inner - scopeBlock, artWidthCap(candidate));
|
||||
if (art >= ART_COMFORT_MIN) {
|
||||
if (art >= (stacksAsTablet ? TABLET_ART_COMFORT_MIN : ART_COMFORT_MIN)) {
|
||||
tier = candidate;
|
||||
deck = candidateDeck;
|
||||
break;
|
||||
@@ -545,7 +682,8 @@ export function getTabletCompanionLayout(
|
||||
availableWidth: number,
|
||||
availableHeight: number,
|
||||
showVisualizer: boolean,
|
||||
fontScale = 1
|
||||
fontScale = 1,
|
||||
companion: 'queue' | 'lyrics' = 'queue'
|
||||
): TabletCompanionLayout | null {
|
||||
const shellWidth = Math.min(
|
||||
Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2),
|
||||
@@ -553,17 +691,33 @@ export function getTabletCompanionLayout(
|
||||
);
|
||||
if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null;
|
||||
|
||||
const lyrics = companion === 'lyrics';
|
||||
// The player floor constrains lyrics only. Lyrics is the companion that asks
|
||||
// for a majority of the shell, so it is the only one that can starve the
|
||||
// player; the queue's 320-400 band never could, and applying the floor to it
|
||||
// as well squeezed the queue *below* its own minimum on a small tablet.
|
||||
const companionCeiling = lyrics
|
||||
? Math.min(
|
||||
TABLET_COMPANION_LYRICS_MAX_WIDTH,
|
||||
Math.max(0, shellWidth - TABLET_COMPANION_GAP - TABLET_PLAYER_REGION_MIN)
|
||||
)
|
||||
: TABLET_COMPANION_MAX_WIDTH;
|
||||
const companionWidth = Math.round(
|
||||
clamp(shellWidth * 0.34, TABLET_COMPANION_MIN_WIDTH, TABLET_COMPANION_MAX_WIDTH)
|
||||
Math.min(
|
||||
companionCeiling,
|
||||
Math.max(
|
||||
lyrics ? TABLET_COMPANION_LYRICS_MIN_WIDTH : TABLET_COMPANION_MIN_WIDTH,
|
||||
shellWidth * (lyrics ? TABLET_COMPANION_LYRICS_WIDTH_RATIO : TABLET_COMPANION_WIDTH_RATIO)
|
||||
)
|
||||
)
|
||||
);
|
||||
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;
|
||||
// The player region always stacks now, so the old pair of gates — "tall
|
||||
// enough to stack (760)" *or* "wide enough to go side-by-side (600)" — asked
|
||||
// a question that no longer has two answers, and rejected the case they were
|
||||
// both written for: a 752dp-tall tablet whose player region the lyrics pane
|
||||
// had narrowed to 456. One gate, the same height bar the player itself uses.
|
||||
if (availableHeight < TABLET_STACK_MIN_HEIGHT) return null;
|
||||
return {
|
||||
presentation: 'tablet-companion',
|
||||
shellWidth,
|
||||
@@ -574,8 +728,11 @@ export function getTabletCompanionLayout(
|
||||
playerRegionWidth,
|
||||
availableHeight,
|
||||
showVisualizer,
|
||||
forceWide,
|
||||
fontScale
|
||||
false,
|
||||
fontScale,
|
||||
// This tier only exists on a tablet, so the region keeps tablet sizing
|
||||
// however narrow the companion has made it.
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
|
||||
const ARTIST_IMAGE_AUTO_POLICY_KEY = 'artist_image_auto_policy';
|
||||
const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen';
|
||||
const PLAYER_DOCK_KEY = 'player_dock_open';
|
||||
const NOW_PLAYING_COMPANION_OPEN_KEY = 'now_playing_companion_open';
|
||||
|
||||
/** Which visualizer the now-playing scope stage shows. */
|
||||
export type ScopeMode = 'spectrum' | 'scope';
|
||||
@@ -80,6 +81,12 @@ interface SettingsStore {
|
||||
/** Whether the now-playing top half shows lyrics instead of art/scope. */
|
||||
lyricsVisible: boolean;
|
||||
nowPlayingCompanion: NowPlayingCompanion;
|
||||
/**
|
||||
* Whether the tablet queue/lyrics pane is out beside the player. Like
|
||||
* `playerDockOpen` this is a wish: only a window that can seat a pane without
|
||||
* squeezing the player honours it, and the layout arbitrates.
|
||||
*/
|
||||
nowPlayingCompanionOpen: boolean;
|
||||
homeGreetingTextMode: HomeGreetingTextMode;
|
||||
listeningHistoryEnabled: boolean;
|
||||
artistImageAutoPolicy: ArtistImageAutoPolicy;
|
||||
@@ -94,6 +101,7 @@ interface SettingsStore {
|
||||
setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise<void>;
|
||||
setLyricsVisible: (visible: boolean) => Promise<void>;
|
||||
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
|
||||
setNowPlayingCompanionOpen: (open: boolean) => Promise<void>;
|
||||
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
|
||||
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
|
||||
setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise<void>;
|
||||
@@ -109,6 +117,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
nowPlayingScopeStyle: 'rail',
|
||||
lyricsVisible: false,
|
||||
nowPlayingCompanion: 'queue',
|
||||
nowPlayingCompanionOpen: false,
|
||||
homeGreetingTextMode: 'messages',
|
||||
listeningHistoryEnabled: true,
|
||||
artistImageAutoPolicy: 'wifi',
|
||||
@@ -146,6 +155,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
);
|
||||
const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1';
|
||||
const playerDockOpen = values[PLAYER_DOCK_KEY] ?? null;
|
||||
const companionOpen = values[NOW_PLAYING_COMPANION_OPEN_KEY] ?? null;
|
||||
set({
|
||||
artistGroupingMode: parseGroupingMode(grouping),
|
||||
includeSingles: parseBoolean(includeSingles),
|
||||
@@ -155,6 +165,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
nowPlayingScopeStyle: parseScopeStyle(scopeStyle),
|
||||
lyricsVisible: parseBoolean(lyricsVisible),
|
||||
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
|
||||
nowPlayingCompanionOpen: parseBoolean(companionOpen),
|
||||
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
|
||||
listeningHistoryEnabled,
|
||||
artistImageAutoPolicy,
|
||||
@@ -193,6 +204,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
await AstraLibraryData.setSettings({ [PLAYER_DOCK_KEY]: open ? 'true' : 'false' });
|
||||
},
|
||||
|
||||
setNowPlayingCompanionOpen: async (open) => {
|
||||
if (get().nowPlayingCompanionOpen === open) return;
|
||||
set({ nowPlayingCompanionOpen: open });
|
||||
await AstraLibraryData.setSettings({
|
||||
[NOW_PLAYING_COMPANION_OPEN_KEY]: open ? 'true' : 'false',
|
||||
});
|
||||
},
|
||||
|
||||
setNowPlayingScopeStyle: async (style) => {
|
||||
if (get().nowPlayingScopeStyle === style) return;
|
||||
set({ nowPlayingScopeStyle: style });
|
||||
|
||||
Reference in New Issue
Block a user