mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +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?>? ->
|
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)
|
||||||
|
|||||||
+6
@@ -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,
|
||||||
|
|||||||
+32
-5
@@ -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) }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user