mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
// Read-only queue sheet for the Desktop Remote: the desktop's current +
|
|
// upcoming tracks, tap-to-play. Deliberately NOT QueueTray — that component is
|
|
// welded to the local RNTP queue store (drag-reorder, swipe-remove,
|
|
// multi-select), none of which applies to a remote snapshot. Uses an INLINE
|
|
// BottomSheet like QueueTray does — BottomSheetModal's portal does not work in
|
|
// this app's screen setups (see queue-tray-sheet gotcha).
|
|
|
|
import { useCallback, useEffect, useMemo } from 'react';
|
|
import { Pressable, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import BottomSheet, {
|
|
BottomSheetBackdrop,
|
|
type BottomSheetBackdropProps,
|
|
useBottomSheetScrollableCreator,
|
|
} from '@gorhom/bottom-sheet';
|
|
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
|
|
import { Text } from '@/components/Text';
|
|
import { radius, spacing } from '@/theme';
|
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
|
import { formatDuration } from '@/lib/format';
|
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
|
import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
|
|
|
|
interface RemoteQueueSheetProps {
|
|
onClose: () => void;
|
|
embedded?: boolean;
|
|
}
|
|
|
|
export function RemoteQueueSheet({ onClose, embedded = false }: RemoteQueueSheetProps) {
|
|
const styles = useStyles();
|
|
const colors = useColors();
|
|
const ripple = useRipple();
|
|
const queue = useDesktopRemoteStore((s) => s.queue);
|
|
const snapPoints = useMemo(() => ['58%', '100%'], []);
|
|
const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
|
|
|
|
// The SSE stream keeps the queue fresh while connected; refresh once on open
|
|
// in case the stream fell back to snapshot polling (which has no queue).
|
|
useEffect(() => {
|
|
void useDesktopRemoteStore.getState().refreshQueue();
|
|
}, []);
|
|
|
|
const items = queue?.items ?? [];
|
|
const upcomingCount = items.filter((item) => !item.isCurrent).length;
|
|
|
|
const renderBackdrop = useCallback(
|
|
(props: BottomSheetBackdropProps) => (
|
|
<BottomSheetBackdrop
|
|
{...props}
|
|
appearsOnIndex={0}
|
|
disappearsOnIndex={-1}
|
|
pressBehavior="close"
|
|
opacity={0.58}
|
|
/>
|
|
),
|
|
[]
|
|
);
|
|
|
|
const playItem = useCallback(
|
|
(item: DesktopRemoteQueueItem) => {
|
|
if (item.isCurrent) return;
|
|
void useDesktopRemoteStore.getState().playQueueItem(item.queueId);
|
|
onClose();
|
|
},
|
|
[onClose]
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
({ item }: ListRenderItemInfo<DesktopRemoteQueueItem>) => (
|
|
<Pressable
|
|
android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
|
|
style={styles.row}
|
|
onPress={() => playItem(item)}
|
|
disabled={item.isCurrent}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
item.isCurrent ? `Now playing: ${item.title}` : `Play ${item.title} on desktop`
|
|
}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<Text
|
|
variant="body"
|
|
numberOfLines={1}
|
|
style={item.isCurrent ? styles.titleActive : undefined}
|
|
>
|
|
{item.title || 'Unknown title'}
|
|
</Text>
|
|
<Text variant="label" numberOfLines={1} color={colors.textTertiary}>
|
|
{item.artist || 'Unknown artist'}
|
|
</Text>
|
|
</View>
|
|
{item.isCurrent ? (
|
|
<Ionicons name="volume-high" size={18} color={colors.accent} />
|
|
) : item.durationSeconds !== null ? (
|
|
<Text variant="label" color={colors.textTertiary}>
|
|
{formatDuration(item.durationSeconds)}
|
|
</Text>
|
|
) : null}
|
|
</Pressable>
|
|
),
|
|
[playItem, colors, styles, ripple]
|
|
);
|
|
|
|
const body = (
|
|
<>
|
|
<View style={styles.headerRow}>
|
|
<Text variant="heading">Desktop queue</Text>
|
|
<Text variant="label" color={colors.textTertiary}>
|
|
{upcomingCount === 1 ? '1 song up next' : `${upcomingCount} songs up next`}
|
|
</Text>
|
|
</View>
|
|
<FlashList
|
|
data={items}
|
|
keyExtractor={(item) => item.queueId}
|
|
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
|
|
renderItem={renderItem}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
ListEmptyComponent={
|
|
<View style={styles.empty}>
|
|
<Text variant="body" color={colors.textSecondary}>
|
|
The desktop queue is empty.
|
|
</Text>
|
|
</View>
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
if (embedded) {
|
|
return <View style={styles.embeddedRoot}>{body}</View>;
|
|
}
|
|
|
|
return (
|
|
<BottomSheet
|
|
index={0}
|
|
snapPoints={snapPoints}
|
|
enableDynamicSizing={false}
|
|
enablePanDownToClose
|
|
onClose={onClose}
|
|
backdropComponent={renderBackdrop}
|
|
backgroundStyle={styles.sheetBg}
|
|
handleIndicatorStyle={styles.handle}
|
|
>
|
|
{body}
|
|
</BottomSheet>
|
|
);
|
|
}
|
|
|
|
const useStyles = createThemedStyles((colors) => ({
|
|
sheetBg: {
|
|
backgroundColor: colors.bgSecondary,
|
|
borderRadius: radius.lg,
|
|
},
|
|
handle: {
|
|
backgroundColor: colors.textTertiary,
|
|
},
|
|
embeddedRoot: {
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'baseline',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.sm,
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.xl,
|
|
},
|
|
row: {
|
|
minHeight: 56,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.md,
|
|
},
|
|
rowText: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
titleActive: {
|
|
color: colors.accent,
|
|
},
|
|
empty: {
|
|
paddingVertical: spacing.xl,
|
|
alignItems: 'center',
|
|
},
|
|
}));
|