performance improvements

This commit is contained in:
Boof2015
2026-07-12 02:02:37 -04:00
parent 9c7d37772b
commit 5cc1099853
21 changed files with 453 additions and 165 deletions
+27 -13
View File
@@ -7,7 +7,7 @@ import {
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { usePathname, useRouter } from 'expo-router';
import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve';
@@ -21,6 +21,7 @@ import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { artworkThumbFromSource } from '@/library/artwork';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
import {
@@ -56,6 +57,13 @@ function MiniProgress({
);
}
/** Phone-target progress: subscribes here so the 2Hz tick skips the whole pill. */
function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) {
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
return <MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />;
}
/**
* Persistent floating mini-player (M3 redesign): a rounded pill above the tab
* bar with the live filled-line spectrum drifting behind the metadata. Tapping
@@ -65,11 +73,10 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const pathname = usePathname();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
const desktopConnectionState = useDesktopRemoteStore((s) => s.connectionState);
const desktopSnapshot = useDesktopRemoteStore((s) => s.snapshot);
@@ -83,8 +90,6 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const phonePresentation = getPhonePlaybackPresentation({
track,
playbackState,
currentTime,
duration,
});
const desktopPresentation = getDesktopPlaybackPresentation({
connection: desktopConnection,
@@ -102,7 +107,9 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const isDesktop = presentation.target === 'desktop';
const isPlaying = presentation.playbackState === 'playing';
const isLoading = presentation.playbackState === 'loading';
const liveScopeActive = visible && scopeActive && !isDesktop;
// The pill sits underneath the now-playing transparentModal; don't burn a
// second live-scope frame loop while it's fully occluded.
const liveScopeActive = visible && scopeActive && !isDesktop && pathname !== '/now-playing';
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
const onTogglePlay = () => {
@@ -137,7 +144,6 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
@@ -155,7 +161,11 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
<View style={styles.row}>
<View style={styles.art}>
{presentation.artworkUri ? (
<Image source={{ uri: presentation.artworkUri }} style={styles.artImage} contentFit="cover" />
<Image
source={{ uri: artworkThumbFromSource(presentation.artworkUri) ?? presentation.artworkUri }}
style={styles.artImage}
contentFit="cover"
/>
) : (
<AstraLogo size={20} />
)}
@@ -193,11 +203,15 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
</View>
{presentation.hasTrack ? (
<MiniProgress
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
/>
isDesktop ? (
<MiniProgress
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
/>
) : (
<PhoneMiniProgress isPlaying={isPlaying} />
)
) : null}
</Pressable>
<PlaybackTargetPicker
+64 -30
View File
@@ -9,6 +9,7 @@ import {
SkiaPictureView,
StrokeCap,
StrokeJoin,
type SkPath,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
@@ -20,6 +21,8 @@ interface OscilloscopeWaveProps {
active: boolean;
width: number;
height: number;
/** Live render cadence; 0 means display-sync. */
frameMs?: number;
color?: string;
lineWidth?: number;
glow?: boolean;
@@ -56,6 +59,37 @@ function makeStrokePaint(color: string, width: number, alpha = 1) {
return paint;
}
function writeWavePath(
samples: Float32Array,
sampleCount: number,
width: number,
height: number,
lineWidth: number,
gain: number,
path: SkPath
) {
path.reset();
const n = Math.min(sampleCount, samples.length);
if (n < 2 || width <= 0 || height <= 0) return;
const mid = height / 2;
const amp = mid - lineWidth;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
let v = samples[i] * gain;
// Per-track gain targets ~85% of full scale, so this only catches the rare
// intra-track peak that runs a touch hotter than the analyzed sample peak.
if (v < -1) v = -1;
else if (v > 1) v = 1;
return mid - v * amp;
};
path.moveTo(0, yAt(0));
for (let i = 1; i < n; i++) {
path.lineTo(xAt(i), yAt(i));
}
}
function buildPicture(
samples: Float32Array,
sampleCount: number,
@@ -68,32 +102,13 @@ function buildPicture(
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
const n = Math.min(sampleCount, samples.length);
const path = Skia.Path.Make();
writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path);
if (n >= 2 && width > 0 && height > 0) {
const path = Skia.Path.Make();
const mid = height / 2;
const amp = mid - lineWidth;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
let v = samples[i] * gain;
// Per-track gain targets ~85% of full scale, so this only catches the rare
// intra-track peak that runs a touch hotter than the analyzed sample peak.
if (v < -1) v = -1;
else if (v > 1) v = 1;
return mid - v * amp;
};
path.moveTo(0, yAt(0));
for (let i = 1; i < n; i++) {
path.lineTo(xAt(i), yAt(i));
}
if (glow) {
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
}
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
if (glow) {
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
}
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
return recorder.finishRecordingAsPicture();
}
@@ -111,6 +126,7 @@ export function OscilloscopeWave({
active,
width,
height,
frameMs = 16,
color: colorProp,
lineWidth = 2,
glow = false,
@@ -141,24 +157,42 @@ export function OscilloscopeWave({
let mounted = true;
let raf = 0;
let lastDraw = 0;
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
// Paints and the path live for the whole effect run; per-frame allocation
// was measurable GC/JSI churn at 60fps.
const strokePaint = makeStrokePaint(color, lineWidth);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null;
const bounds = Skia.XYWHRect(0, 0, width, height);
const path = Skia.Path.Make();
const draw = (sampleCount: number) => {
const gain = useScopeStore.getState().oscGain;
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow, gain);
api.setJsiProperty(view.nativeId, 'picture', picture);
writeWavePath(values, sampleCount, width, height, lineWidth, gain, path);
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(bounds);
if (glowPaint) canvas.drawPath(path, glowPaint);
canvas.drawPath(path, strokePaint);
api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture());
api.requestRedraw(view.nativeId);
};
values.fill(0);
draw(values.length);
// Inactive: leave the flat line and schedule nothing instead of idling a rAF.
if (!active) return;
const tick = () => {
const tick = (t: number) => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (!active) return;
if (drawThreshold > 0 && t - lastDraw < drawThreshold) return;
const n = AstraScope.getOscilloscopeFrame(values);
if (n > 0) draw(n);
if (n > 0) {
lastDraw = t;
draw(n);
}
};
raf = requestAnimationFrame(tick);
@@ -166,7 +200,7 @@ export function OscilloscopeWave({
mounted = false;
cancelAnimationFrame(raf);
};
}, [active, color, glow, height, lineWidth, width]);
}, [active, color, frameMs, glow, height, lineWidth, width]);
if (width <= 0 || height <= 0) return null;
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
+51 -20
View File
@@ -10,6 +10,7 @@ import {
StrokeCap,
StrokeJoin,
TileMode,
type SkPath,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
@@ -119,10 +120,18 @@ function makeFadePaint(color: string, startAlpha: number, endAlpha: number, x0:
return paint;
}
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
const line = Skia.Path.Make();
function writePaths(
values: ArrayLike<number>,
width: number,
height: number,
pad: number,
line: SkPath,
fill: SkPath
) {
line.reset();
fill.reset();
const n = values.length;
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
if (n < 2 || width <= 0 || height <= 0) return;
const usableH = height - pad * 2;
const xAt = (i: number) => (i / (n - 1)) * width;
@@ -139,11 +148,16 @@ function buildPaths(values: ArrayLike<number>, width: number, height: number, pa
}
line.lineTo(xAt(n - 1), yAt(n - 1));
const fill = line.copy();
fill.addPath(line);
fill.lineTo(width, height);
fill.lineTo(0, height);
fill.close();
}
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
const line = Skia.Path.Make();
const fill = Skia.Path.Make();
writePaths(values, width, height, pad, line, fill);
return { line, fill };
}
@@ -319,7 +333,9 @@ export function SpectrumCurve({
const color = colorProp ?? themeColors.accent;
const edgeFadeColor = edgeFadeColorProp ?? themeColors.bgPrimary;
const viewRef = useRef<SkiaPictureView | null>(null);
const activePointCount = Math.max(2, Math.floor(width));
// Half a point per pixel, capped: the quadTo midpoint smoothing makes denser
// sampling visually indistinguishable while doubling per-frame path cost.
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
const staticValues = useMemo(
() => values ?? new Float32Array(resolvedPointCount),
@@ -374,22 +390,37 @@ export function SpectrumCurve({
const renderValues = new Float32Array(resolvedPointCount);
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
// Paints, shaders, and paths live for the whole effect run: allocating them
// (and the gradient shaders) per frame was measurable GC/JSI churn at 60fps.
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null;
const fillPaint = makeFillPaint(color, height, fillOpacity);
const fadeWidth = Math.min(edgeFadeWidth, width * 0.5);
const fade =
edgeFade && fadeWidth > 0
? {
leftRect: Skia.XYWHRect(0, 0, fadeWidth, height),
leftPaint: makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth),
rightRect: Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height),
rightPaint: makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width),
}
: null;
const bounds = Skia.XYWHRect(0, 0, width, height);
const linePath = Skia.Path.Make();
const fillPath = Skia.Path.Make();
const draw = () => {
const picture = buildPicture(
renderValues,
width,
height,
color,
lineWidth,
lineOpacity,
fillOpacity,
glow,
glowOpacity,
edgeFade,
edgeFadeColor,
edgeFadeWidth
);
api.setJsiProperty(view.nativeId, 'picture', picture);
writePaths(renderValues, width, height, lineWidth, linePath, fillPath);
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(bounds);
canvas.drawPath(fillPath, fillPaint);
if (glowPaint) canvas.drawPath(linePath, glowPaint);
canvas.drawPath(linePath, strokePaint);
if (fade) {
canvas.drawRect(fade.leftRect, fade.leftPaint);
canvas.drawRect(fade.rightRect, fade.rightPaint);
}
api.setJsiProperty(view.nativeId, 'picture', recorder.finishRecordingAsPicture());
api.requestRedraw(view.nativeId);
};
+3 -1
View File
@@ -23,7 +23,9 @@ import { commitHaptic, tickHaptic } from '@/lib/haptics';
type IconName = keyof typeof Ionicons.glyphMap;
const SWIPE_ACTIVE_OFFSET_X = 10;
const SWIPE_FAIL_OFFSET_Y = 30;
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
// 30px before the pan failed and the surrounding scrollable could win.
const SWIPE_FAIL_OFFSET_Y = 12;
export interface SwipeAction {
icon: IconName;
+8 -2
View File
@@ -12,7 +12,9 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { useScopeActive } from '@/scope/scopeStore';
const CANVAS_HEIGHT = 96;
const STAGE_FRAME_MS = 0; // display-sync
// 60fps cap: display-sync (0) pinned the JS thread at 120Hz on high-refresh
// devices and starved every other animation.
const STAGE_FRAME_MS = 16;
type Mode = 'spectrum' | 'scope';
@@ -23,6 +25,8 @@ interface VisualizerProps {
showChrome?: boolean;
mode?: Mode;
edgeFade?: boolean;
/** Freeze the live scopes without unmounting (e.g. while occluded by an overlay). */
paused?: boolean;
}
/**
@@ -37,12 +41,13 @@ export function Visualizer({
showChrome = true,
mode: controlledMode,
edgeFade = false,
paused = false,
}: VisualizerProps) {
const styles = useStyles();
const colors = useColors();
const [uncontrolledMode, setUncontrolledMode] = useState<Mode>('spectrum');
const mode = controlledMode ?? uncontrolledMode;
const scopeActive = useScopeActive();
const scopeActive = useScopeActive() && !paused;
const spectrumActive = scopeActive && mode === 'spectrum';
const scopeWaveActive = scopeActive && mode === 'scope';
@@ -75,6 +80,7 @@ export function Visualizer({
) : (
<OscilloscopeWave
active={scopeWaveActive}
frameMs={STAGE_FRAME_MS}
width={width}
height={height}
glow
+6 -6
View File
@@ -31,9 +31,6 @@ const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
type WaveformQuality = 'preview' | 'accurate';
interface WaveformSeekBarProps {
currentTime: number;
duration: number;
isPlaying?: boolean;
onSeek: (seconds: number) => void;
height?: number;
touchPadding?: number;
@@ -48,11 +45,11 @@ const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
* played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's
* tap/drag + pending-seek "hold" state machine verbatim so seeking behaves
* identically. Peaks load offline (getWaveform) and fall back to flat bars.
*
* Phone-target only: progress comes straight from the player store so the 2Hz
* tick re-renders this leaf, not the whole now-playing tree.
*/
export function WaveformSeekBar({
currentTime,
duration,
isPlaying = false,
onSeek,
height = CANVAS_HEIGHT,
touchPadding = spacing.md,
@@ -60,6 +57,9 @@ export function WaveformSeekBar({
}: WaveformSeekBarProps) {
const styles = useStyles();
const colors = useColors();
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const isPlaying = usePlayerStore((s) => s.playbackState === 'playing');
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0);
const pendingSeek = usePlayerStore((s) => s.pendingSeek);
+1 -1
View File
@@ -151,7 +151,7 @@ export function EQGraph({
active={spectrumActive}
width={width}
height={height}
frameMs={0}
frameMs={16}
color={colors.accent}
lineOpacity={0.22}
fillOpacity={0.5}
+5 -4
View File
@@ -15,14 +15,13 @@ import { SeekBar } from '@/components/SeekBar';
import { LyricsBand } from './LyricsBand';
import { spacing, radius } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlayerStore } from '@/stores/playerStore';
import { useLyricsStore } from '@/stores/lyricsStore';
import { getLyricsPayloadSourceLabel } from '@/lyrics/presentation';
import type { Track } from '@/types/audio';
interface LyricsViewProps {
track: Track;
currentTime: number;
duration: number;
isPlaying: boolean;
isLoading: boolean;
isFavorite: boolean;
@@ -37,8 +36,6 @@ interface LyricsViewProps {
export function LyricsView({
track,
currentTime,
duration,
isPlaying,
isLoading,
isFavorite,
@@ -52,6 +49,10 @@ export function LyricsView({
}: LyricsViewProps) {
const styles = useStyles();
const colors = useColors();
// Lyrics mode is phone-target only, so progress comes straight from the
// player store — the 2Hz tick re-renders this takeover, not the whole screen.
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null);
const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null;
+51 -25
View File
@@ -44,6 +44,7 @@ import {
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
import { artworkThumbFromSource } from '@/library/artwork';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
import {
@@ -86,7 +87,9 @@ function trackArtist(track: RntpTrack): string {
}
function artworkUri(track: RntpTrack): string | undefined {
return typeof track.artwork === 'string' ? track.artwork : undefined;
// RNTP tracks carry the full-size cover; 42px rows want the generated thumb.
if (typeof track.artwork !== 'string') return undefined;
return artworkThumbFromSource(track.artwork) ?? undefined;
}
function queueCountLabel(count: number): string {
@@ -130,7 +133,12 @@ function reconcileQueueEntries(
return tracks.map((track) => {
const identity = rntpKey(track);
const reused = available.get(identity)?.shift();
if (reused) return { ...reused, track, identity };
if (reused) {
// Same track object → same entry object, so memo'd rows bail out when
// only other parts of the queue changed (e.g. a track advance).
if (reused.track === track) return reused;
return { ...reused, track, identity };
}
const key = `${identity}:${nextSerial.current}`;
nextSerial.current += 1;
@@ -142,7 +150,9 @@ interface QueueTrayProps {
onClose: () => void;
}
export function QueueTray({ onClose }: QueueTrayProps) {
// memo: the parent now-playing screen re-renders on store changes; the tray's
// ~15-hook body shouldn't re-execute unless its own inputs change.
export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
@@ -156,6 +166,24 @@ export function QueueTray({ onClose }: QueueTrayProps) {
// freeze. Clamping the list container to the window height caps the viewport
// no matter what the sheet reports; both snap points stay unaffected.
const listClampStyle = useMemo(() => ({ maxHeight: windowHeight }), [windowHeight]);
// Same bug, milder symptom: a viewport measured during the open animation can
// stick at the clamp height (taller than the sheet's real content area), which
// silently shortens the scroll range — the last few rows become unreachable.
// Mounting the list only after the sheet settles removes the bad window.
const [listReady, setListReady] = useState(false);
const onSheetChange = useCallback((index: number) => {
if (index >= 0) setListReady(true);
}, []);
// Bottom padding clears the gesture-nav inset so the last row is fully
// scrollable into view at the 100% snap.
const listContentStyle = useMemo(
() => [styles.listContent, { paddingBottom: spacing.xxl + insets.bottom }],
[styles, insets.bottom]
);
const listContentEditStyle = useMemo(
() => [styles.listContent, { paddingBottom: spacing.xxl * 2 + insets.bottom }],
[styles, insets.bottom]
);
const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true);
const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined;
@@ -551,6 +579,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
enablePanDownToClose
enableContentPanningGesture={!editMode}
enableHandlePanningGesture
onChange={onSheetChange}
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
@@ -603,23 +632,24 @@ export function QueueTray({ onClose }: QueueTrayProps) {
Up next
</Text>
<FlashList
data={entries}
scrollEnabled={!editMode}
style={listClampStyle}
keyExtractor={(item) => item.key}
drawDistance={QUEUE_ROW_HEIGHT * 12}
maintainVisibleContentPosition={{ disabled: true }}
renderScrollComponent={renderFlashListScrollComponent}
renderItem={renderItem}
extraData={listExtraData}
contentContainerStyle={[
styles.listContent,
editMode && selectedCount > 0 ? styles.listContentWithActionBar : null,
]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
/>
{listReady ? (
<FlashList
data={entries}
scrollEnabled={!editMode}
style={listClampStyle}
keyExtractor={(item) => item.key}
drawDistance={QUEUE_ROW_HEIGHT * 12}
maintainVisibleContentPosition={{ disabled: true }}
renderScrollComponent={renderFlashListScrollComponent}
renderItem={renderItem}
extraData={listExtraData}
contentContainerStyle={
editMode && selectedCount > 0 ? listContentEditStyle : listContentStyle
}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
/>
) : null}
{editMode && selectedCount > 0 ? (
<View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}>
@@ -649,7 +679,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
) : null}
</BottomSheet>
);
}
});
const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) {
const styles = useStyles();
@@ -967,12 +997,8 @@ const useStyles = createThemedStyles((colors) => ({
backgroundColor: colors.glassBg,
},
listContent: {
paddingBottom: spacing.xxl,
flexGrow: 1,
},
listContentWithActionBar: {
paddingBottom: spacing.xxl * 2,
},
empty: {
alignItems: 'center',
justifyContent: 'center',