fullscreen player tablet layout

This commit is contained in:
Boof2015
2026-08-01 01:52:29 -04:00
parent a5e23ce49a
commit 9764e828bd
10 changed files with 537 additions and 107 deletions
@@ -126,6 +126,9 @@ class AstraQueueModule : Module() {
Prop("palette") { view, values: Map<String, Any?>? -> Prop("palette") { view, values: Map<String, Any?>? ->
view.palette = QueuePalette.from(values) view.palette = QueuePalette.from(values)
} }
Prop("paneMode") { view, paneMode: Boolean? ->
view.paneMode = paneMode == true
}
OnViewDidUpdateProps { view -> OnViewDidUpdateProps { view ->
view.setPlaybackRequestListener { entryId, revision -> view.setPlaybackRequestListener { entryId, revision ->
emitPlaybackRequest(entryId, revision) emitPlaybackRequest(entryId, revision)
@@ -22,6 +22,12 @@ class AstraQueueView(
content.palette = value content.palette = value
} }
var paneMode: Boolean = false
set(value) {
field = value
content.paneMode = value
}
init { init {
addView( addView(
content, content,
@@ -3,6 +3,7 @@ package expo.modules.astralibraryscanner.queue
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.Typeface import android.graphics.Typeface
@@ -106,6 +107,21 @@ class QueueContentView(
applyPalette() 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 var active: Boolean = true
set(value) { set(value) {
field = value field = value
@@ -559,10 +575,14 @@ class QueueContentView(
} }
private fun applyPalette() { private fun applyPalette() {
background = if (sheetMode) { background = when {
topRounded(palette.background, 16f) sheetMode -> topRounded(palette.background, 16f)
} else { // The companion pane is a region of the player's own surface, not a panel
ColorDrawable(palette.background) // 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) sheetHandle.background = rounded(palette.divider, 999f)
titleView.setTextColor(palette.text) titleView.setTextColor(palette.text)
@@ -930,7 +950,14 @@ class QueueContentView(
row.artwork.visibility = if (editing) GONE else VISIBLE row.artwork.visibility = if (editing) GONE else VISIBLE
row.handle.visibility = if (editing) GONE else VISIBLE row.handle.visibility = if (editing) GONE else VISIBLE
row.setSurfaceColor( 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) loadArtwork(row.artwork, item.artworkThumbPath)
row.setOnClickListener { onRowClick?.invoke(item) } row.setOnClickListener { onRowClick?.invoke(item) }
+8
View File
@@ -70,6 +70,14 @@ declare class AstraQueueModuleType extends NativeModule<AstraQueueEvents> {
export interface AstraQueueViewProps extends ViewProps { export interface AstraQueueViewProps extends ViewProps {
active: boolean; active: boolean;
palette: NativeQueuePalette; 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 { function nativeColor(value: string): number {
+41 -4
View File
@@ -27,6 +27,32 @@ import type { Track } from '@/types/audio';
const ANCHOR_RATIO = 0.4; const ANCHOR_RATIO = 0.4;
const H_PADDING = 22; 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 // 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 // position reporting + poll/smoothing) that the desktop doesn't have, so advance
// the lyrics clock by this much. Tune to taste — bigger = earlier highlight. // the lyrics clock by this much. Tune to taste — bigger = earlier highlight.
@@ -37,13 +63,23 @@ interface LyricsBandProps {
duration: number; duration: number;
isPlaying: boolean; isPlaying: boolean;
onSeek: (seconds: number) => void; 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 { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); 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 colors = useColors();
const ripple = useRipple(); const ripple = useRipple();
const entry = useLyricsStore((s) => s.byPath[track.path]); 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 // 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. // 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 --- // --- auto-scroll centering ---
const scrollRef = useRef<ScrollView>(null); const scrollRef = useRef<ScrollView>(null);
@@ -146,7 +183,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
return ( return (
<View style={{ flex: 1 }} onLayout={onContainerLayout}> <View style={{ flex: 1 }} onLayout={onContainerLayout}>
<ScrollView <ScrollView
contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: H_PADDING }} contentContainerStyle={{ paddingVertical: 24, paddingHorizontal: hPadding }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}> <Text variant="body" color={colors.textSecondary} style={{ lineHeight: 28 }}>
@@ -178,7 +215,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
onScrollBeginDrag={() => setFollowPaused(true)} onScrollBeginDrag={() => setFollowPaused(true)}
contentContainerStyle={{ contentContainerStyle={{
paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120, paddingVertical: size.h > 0 ? Math.round(size.h * ANCHOR_RATIO) : 120,
paddingHorizontal: H_PADDING, paddingHorizontal: hPadding,
alignItems: 'stretch', alignItems: 'stretch',
}} }}
> >
@@ -1,9 +1,7 @@
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { SegmentedControl } from '@/components/SegmentedControl';
import { LyricsBand } from '@/components/lyrics/LyricsBand'; import { LyricsBand } from '@/components/lyrics/LyricsBand';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController'; import { seekTo } from '@/audio/playbackController';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
import { import {
getNowPlayingTrackTransitionKey, getNowPlayingTrackTransitionKey,
@@ -11,18 +9,12 @@ import {
} from './nowPlayingTrackTransition'; } from './nowPlayingTrackTransition';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import type { NowPlayingCompanion } from './nowPlayingPreferences';
import type { Track } from '@/types/audio'; import type { Track } from '@/types/audio';
import { import {
AstraQueueView, AstraQueueView,
toNativeQueuePalette, toNativeQueuePalette,
} from '../../../modules/astra-library-scanner'; } from '../../../modules/astra-library-scanner';
const COMPANION_SEGMENTS = [
{ key: 'queue', label: 'Queue' },
{ key: 'lyrics', label: 'Lyrics' },
];
const noop = () => {}; const noop = () => {};
interface NowPlayingCompanionPaneProps { interface NowPlayingCompanionPaneProps {
@@ -40,7 +32,6 @@ export function NowPlayingCompanionPane({
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const companion = useSettingsStore((s) => s.nowPlayingCompanion); const companion = useSettingsStore((s) => s.nowPlayingCompanion);
const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion);
const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0)); const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0));
const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration)); const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration));
const isPlaying = usePlayerStore( const isPlaying = usePlayerStore(
@@ -51,49 +42,36 @@ export function NowPlayingCompanionPane({
track?.path ?? null track?.path ?? null
); );
const selectCompanion = (next: string) => {
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
if (value === companion) return;
void setCompanion(value);
};
return ( return (
<View style={styles.root}> <View style={styles.root}>
{desktopTarget ? ( {desktopTarget ? (
<RemoteQueueSheet embedded onClose={noop} /> <RemoteQueueSheet embedded onClose={noop} />
) : ( ) : (
<> <View style={styles.content}>
<View style={styles.switcher}> {companion === 'queue' ? (
<SegmentedControl <AstraQueueView
segments={COMPANION_SEGMENTS} active={active}
value={companion} paneMode
onChange={selectCompanion} palette={toNativeQueuePalette(colors)}
style={styles.nativeQueue}
/> />
</View> ) : track ? (
<View style={styles.content}> <NowPlayingTrackFadeThrough
{companion === 'queue' ? ( transitionKey={transitionTrackKey}
<AstraQueueView style={styles.lyricsFrame}
active={active} contentStyle={StyleSheet.absoluteFill}
palette={toNativeQueuePalette(colors)} >
style={styles.nativeQueue} <LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
surface="panel"
onSeek={(seconds) => void seekTo(seconds)}
/> />
) : track ? ( </NowPlayingTrackFadeThrough>
<NowPlayingTrackFadeThrough ) : null}
transitionKey={transitionTrackKey} </View>
style={styles.lyricsFrame}
contentStyle={StyleSheet.absoluteFill}
>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={(seconds) => void seekTo(seconds)}
/>
</NowPlayingTrackFadeThrough>
) : null}
</View>
</>
)} )}
</View> </View>
); );
@@ -101,17 +79,13 @@ export function NowPlayingCompanionPane({
const useStyles = createThemedStyles((colors) => ({ const useStyles = createThemedStyles((colors) => ({
root: { 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, flex: 1,
minWidth: 0, minWidth: 0,
borderLeftColor: colors.glassBorder,
borderLeftWidth: 1,
paddingLeft: spacing.lg,
overflow: 'hidden', overflow: 'hidden',
}, },
switcher: {
paddingHorizontal: spacing.sm,
paddingBottom: spacing.lg,
},
content: { content: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
+49 -18
View File
@@ -39,6 +39,7 @@ import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { TactilePressable } from '@/components/player/TactilePressable'; import { TactilePressable } from '@/components/player/TactilePressable';
import { ScopeRack } from '@/components/player/ScopeRack'; import { ScopeRack } from '@/components/player/ScopeRack';
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
import type { NowPlayingCompanion } from '@/components/player/nowPlayingPreferences';
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
import { import {
@@ -236,6 +237,8 @@ export function NowPlayingOverlay({
const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible); const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible);
const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion); const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion);
const setNowPlayingCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); 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 artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
const libraryTracks = useLibraryStore((s) => s.tracks); const libraryTracks = useLibraryStore((s) => s.tracks);
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
@@ -332,13 +335,21 @@ export function NowPlayingOverlay({
false, false,
fontScale 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, effectiveWidth,
availableHeight, availableHeight,
layoutScopeVisible, 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 layout = tabletCompanionLayout?.playerLayout ?? standardLayout;
const deck = layout.deck; const deck = layout.deck;
// The density tier owns whether there is room for the lyric row, and it is // 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; const shellWidth = tabletCompanionLayout?.shellWidth ?? layout.contentWidth;
// Lyrics takes over only on the phone. Roomy tablets keep the player visible // Lyrics takes over only on the phone. Roomy tablets keep the player visible
// and render lyrics in the companion rail. // 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 = const lyricsMode =
!hasTabletCompanion && !isDesktopTarget && !!track && lyricsVisible; !companionCapable && !isDesktopTarget && !!track && lyricsVisible;
const source = activePresentation.sourceLabel; const source = activePresentation.sourceLabel;
const shellRight = const shellRight =
insets.right + insets.right +
@@ -414,30 +428,45 @@ export function NowPlayingOverlay({
}; };
useEffect(() => { 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) { if (queueOpen) {
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
setQueueOpen(false); setQueueOpen(false);
void setNowPlayingCompanion('queue'); void setNowPlayingCompanion('queue');
void setCompanionOpen(true);
}); });
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
} }
if (lyricsVisible) void setNowPlayingCompanion('lyrics'); if (lyricsVisible) void setNowPlayingCompanion('lyrics');
return undefined; return undefined;
}, [ }, [
companionCapable,
isDesktopTarget, isDesktopTarget,
lyricsVisible, lyricsVisible,
queueOpen, queueOpen,
setCompanionOpen,
setNowPlayingCompanion, 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 = () => { const showQueue = () => {
if (hasTabletCompanion) { if (companionCapable) {
if (!isDesktopTarget) { if (!isDesktopTarget) void setLyricsVisible(false);
void setLyricsVisible(false); toggleCompanion('queue');
void setNowPlayingCompanion('queue');
}
return; return;
} }
suspendPanForChildTransition(); suspendPanForChildTransition();
@@ -558,8 +587,8 @@ export function NowPlayingOverlay({
}; };
const showLyrics = () => { const showLyrics = () => {
if (hasTabletCompanion) { if (companionCapable) {
void setNowPlayingCompanion('lyrics'); toggleCompanion('lyrics');
return; return;
} }
setPhoneLyricsVisible(!lyricsVisible); setPhoneLyricsVisible(!lyricsVisible);
@@ -1791,11 +1820,13 @@ export function NowPlayingOverlay({
haptic="selection" haptic="selection"
onPress={showLyrics} onPress={showLyrics}
accessibilityLabel={ accessibilityLabel={
hasTabletCompanion (
? 'Show lyrics in companion' companionCapable
: lyricsVisible ? hasTabletCompanion && nowPlayingCompanion === 'lyrics'
? 'Hide lyrics' : lyricsVisible
: 'Show lyrics' )
? 'Hide lyrics'
: 'Show lyrics'
} }
accessibilityState={{ accessibilityState={{
selected: hasTabletCompanion selected: hasTabletCompanion
+171 -3
View File
@@ -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); 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 = [ const LANDSCAPE = [
{ name: 'Pixel 7 Pro landscape', width: 891, height: 339 }, { name: 'Pixel 7 Pro landscape', width: 891, height: 339 },
{ name: 'S22 landscape', width: 780, height: 312 }, { name: 'S22 landscape', width: 780, height: 312 },
{ name: 'Poco M5 landscape', width: 873, height: 345 }, { name: 'Poco M5 landscape', width: 873, height: 345 },
{ name: 'S25 Ultra landscape', width: 918, height: 363 }, { 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 }, { name: 'very short landscape', width: 800, height: 300 },
] as const; ] 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', () => { test('landscape sizes its panes from their contents, not a fixed split', () => {
for (const fontScale of FONT_SCALES) { for (const fontScale of FONT_SCALES) {
for (const window of LANDSCAPE) { 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', () => { test('adds the companion only to roomy tablet canvases', () => {
for (const device of DEVICES) { for (const device of DEVICES) {
assert.equal(getTabletCompanionLayout(device.width, device.height, true), null); 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 [ for (const [width, height] of [
[600, 840], [600, 840],
[800, 600], [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); assert.equal(getTabletCompanionLayout(width, height, true), null);
} }
for (const [width, height] of [ for (const [width, height] of [
[768, 1024], [768, 1024],
[1024, 600],
[1024, 768], [1024, 768],
[1366, 1024], [1366, 1024],
]) { ]) {
+182 -25
View File
@@ -22,7 +22,37 @@ const MAX_CONTENT_WIDTH = 408;
const CONTENT_SIDE_PADDING = spacing.lg; const CONTENT_SIDE_PADDING = spacing.lg;
const TABLET_MAX_CONTENT_WIDTH = 520; const TABLET_MAX_CONTENT_WIDTH = 520;
const TABLET_ART_SIZE_MAX = 440; 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; export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl;
const WIDE_RIGHT_PANE_MIN = 300; const WIDE_RIGHT_PANE_MIN = 300;
/** /**
@@ -33,8 +63,45 @@ const WIDE_RIGHT_PANE_MIN = 300;
* simply unused. * simply unused.
*/ */
const WIDE_RIGHT_PANE_MAX = 560; const WIDE_RIGHT_PANE_MAX = 560;
const WIDE_ART_SIZE_MAX = 400;
const WIDE_ART_SIZE_MIN = 160; 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 * 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 * 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. * rather than dropping a tier to buy artwork it doesn't need.
*/ */
const ART_COMFORT_MIN = 152; 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 * 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. * 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_MIN_WIDTH = 720;
const TABLET_SHELL_MAX_WIDTH = 1200; const TABLET_SHELL_MAX_WIDTH = 1200;
const TABLET_COMPANION_GAP = spacing.xl; 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_MIN_WIDTH = 320;
const TABLET_COMPANION_MAX_WIDTH = 400; const TABLET_COMPANION_MAX_WIDTH = 400;
const TABLET_STACKED_MIN_HEIGHT = 760; const TABLET_COMPANION_LYRICS_MIN_WIDTH = 440;
const TABLET_WIDE_PLAYER_MIN_WIDTH = 600; const TABLET_COMPANION_LYRICS_MAX_WIDTH = 760;
const TABLET_WIDE_MIN_HEIGHT = 520; 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 NowPlayingPresentation = 'standard' | 'wide';
export type NowPlayingDensity = 'spacious' | 'regular' | 'compact'; export type NowPlayingDensity = 'spacious' | 'regular' | 'compact';
@@ -328,9 +427,28 @@ export function getNowPlayingLayout(
availableHeight: number, availableHeight: number,
showVisualizer: boolean, showVisualizer: boolean,
forceWide = false, 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 { ): 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 = const columnHeight =
availableHeight - availableHeight -
@@ -340,9 +458,10 @@ export function getNowPlayingLayout(
if (isWide) { if (isWide) {
const contentPadding = CONTENT_SIDE_PADDING; const contentPadding = CONTENT_SIDE_PADDING;
const caps = wideRowCaps(availableWidth, availableHeight);
const rowSpace = Math.max( const rowSpace = Math.max(
0, 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 inner = Math.max(0, columnHeight - tier.stageInset * 2);
const fitArt = (space: number) => 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 // 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 // depends on the strip's height. Seed with the tallest strip it could be
@@ -384,7 +503,7 @@ export function getNowPlayingLayout(
clamp( clamp(
artScopeOn * WIDE_SCOPE_WIDTH_RATIO, artScopeOn * WIDE_SCOPE_WIDTH_RATIO,
artScopeOn, artScopeOn,
Math.min(stageSpace, VISUALIZER_WIDTH_MAX) Math.min(stageSpace, caps.scopeMax)
) )
); );
scopeHeight = getScopeHeight(scopeWidth); scopeHeight = getScopeHeight(scopeWidth);
@@ -456,19 +575,37 @@ export function getNowPlayingLayout(
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
const contentPadding = CONTENT_SIDE_PADDING; 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( const contentWidth = Math.max(
0, 0,
Math.min(availableWidth - contentPadding * 2, maxContentWidth) Math.min(availableWidth - contentPadding * 2, maxContentWidth)
); );
const scopeWidth = Math.max( const scopeWidth = Math.max(
0, 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); 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) => 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 // 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 // comfortable band. Deliberately measured against the scope-ON size at every
@@ -483,7 +620,7 @@ export function getNowPlayingLayout(
columnHeight - candidateDeck.height - candidate.stageInset * 2; columnHeight - candidateDeck.height - candidate.stageInset * 2;
const scopeBlock = candidate.scopeTopGap + scopeHeight + candidate.scopeBottomGap; const scopeBlock = candidate.scopeTopGap + scopeHeight + candidate.scopeBottomGap;
const art = Math.min(inner - scopeBlock, artWidthCap(candidate)); 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; tier = candidate;
deck = candidateDeck; deck = candidateDeck;
break; break;
@@ -545,7 +682,8 @@ export function getTabletCompanionLayout(
availableWidth: number, availableWidth: number,
availableHeight: number, availableHeight: number,
showVisualizer: boolean, showVisualizer: boolean,
fontScale = 1 fontScale = 1,
companion: 'queue' | 'lyrics' = 'queue'
): TabletCompanionLayout | null { ): TabletCompanionLayout | null {
const shellWidth = Math.min( const shellWidth = Math.min(
Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2), Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2),
@@ -553,17 +691,33 @@ export function getTabletCompanionLayout(
); );
if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null; 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( 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 playerRegionWidth = shellWidth - TABLET_COMPANION_GAP - companionWidth;
const canStack = availableHeight >= TABLET_STACKED_MIN_HEIGHT; // The player region always stacks now, so the old pair of gates — "tall
const canUseWidePlayer = // enough to stack (760)" *or* "wide enough to go side-by-side (600)" — asked
playerRegionWidth >= TABLET_WIDE_PLAYER_MIN_WIDTH && // a question that no longer has two answers, and rejected the case they were
availableHeight >= TABLET_WIDE_MIN_HEIGHT; // both written for: a 752dp-tall tablet whose player region the lyrics pane
if (!canStack && !canUseWidePlayer) return null; // had narrowed to 456. One gate, the same height bar the player itself uses.
if (availableHeight < TABLET_STACK_MIN_HEIGHT) return null;
const forceWide = canUseWidePlayer && availableWidth > availableHeight;
return { return {
presentation: 'tablet-companion', presentation: 'tablet-companion',
shellWidth, shellWidth,
@@ -574,8 +728,11 @@ export function getTabletCompanionLayout(
playerRegionWidth, playerRegionWidth,
availableHeight, availableHeight,
showVisualizer, showVisualizer,
forceWide, false,
fontScale fontScale,
// This tier only exists on a tablet, so the region keeps tablet sizing
// however narrow the companion has made it.
true
), ),
}; };
} }
+19
View File
@@ -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_AUTO_POLICY_KEY = 'artist_image_auto_policy';
const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen'; const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen';
const PLAYER_DOCK_KEY = 'player_dock_open'; 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. */ /** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope'; export type ScopeMode = 'spectrum' | 'scope';
@@ -80,6 +81,12 @@ interface SettingsStore {
/** Whether the now-playing top half shows lyrics instead of art/scope. */ /** Whether the now-playing top half shows lyrics instead of art/scope. */
lyricsVisible: boolean; lyricsVisible: boolean;
nowPlayingCompanion: NowPlayingCompanion; 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; homeGreetingTextMode: HomeGreetingTextMode;
listeningHistoryEnabled: boolean; listeningHistoryEnabled: boolean;
artistImageAutoPolicy: ArtistImageAutoPolicy; artistImageAutoPolicy: ArtistImageAutoPolicy;
@@ -94,6 +101,7 @@ interface SettingsStore {
setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise<void>; setNowPlayingScopeStyle: (style: NowPlayingScopeStyle) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>; setLyricsVisible: (visible: boolean) => Promise<void>;
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setNowPlayingCompanionOpen: (open: boolean) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>; setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise<void>; setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise<void>;
@@ -109,6 +117,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingScopeStyle: 'rail', nowPlayingScopeStyle: 'rail',
lyricsVisible: false, lyricsVisible: false,
nowPlayingCompanion: 'queue', nowPlayingCompanion: 'queue',
nowPlayingCompanionOpen: false,
homeGreetingTextMode: 'messages', homeGreetingTextMode: 'messages',
listeningHistoryEnabled: true, listeningHistoryEnabled: true,
artistImageAutoPolicy: 'wifi', artistImageAutoPolicy: 'wifi',
@@ -146,6 +155,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
); );
const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1'; const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1';
const playerDockOpen = values[PLAYER_DOCK_KEY] ?? null; const playerDockOpen = values[PLAYER_DOCK_KEY] ?? null;
const companionOpen = values[NOW_PLAYING_COMPANION_OPEN_KEY] ?? null;
set({ set({
artistGroupingMode: parseGroupingMode(grouping), artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles), includeSingles: parseBoolean(includeSingles),
@@ -155,6 +165,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingScopeStyle: parseScopeStyle(scopeStyle), nowPlayingScopeStyle: parseScopeStyle(scopeStyle),
lyricsVisible: parseBoolean(lyricsVisible), lyricsVisible: parseBoolean(lyricsVisible),
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
nowPlayingCompanionOpen: parseBoolean(companionOpen),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
listeningHistoryEnabled, listeningHistoryEnabled,
artistImageAutoPolicy, artistImageAutoPolicy,
@@ -193,6 +204,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
await AstraLibraryData.setSettings({ [PLAYER_DOCK_KEY]: open ? 'true' : 'false' }); 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) => { setNowPlayingScopeStyle: async (style) => {
if (get().nowPlayingScopeStyle === style) return; if (get().nowPlayingScopeStyle === style) return;
set({ nowPlayingScopeStyle: style }); set({ nowPlayingScopeStyle: style });