improve desktop remote

This commit is contained in:
Boof2015
2026-07-06 19:09:54 -04:00
parent 2b19f51a49
commit 995e00e0e7
10 changed files with 990 additions and 293 deletions
+134 -61
View File
@@ -17,9 +17,17 @@ import {
spacing
} from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
import {
getDesktopPlaybackPresentation,
getEffectivePlaybackPresentation,
getPhonePlaybackPresentation,
} from '@/playback/playbackTargetPresentation';
const PILL_HEIGHT = 56;
const ART = 42;
@@ -54,81 +62,146 @@ function MiniProgress({
*/
export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const router = useRouter();
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);
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
const connectDesktop = useDesktopRemoteStore((s) => s.connect);
const scopeActive = useScopeActive();
const [pillWidth, setPillWidth] = useState(0);
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
if (!track) return null;
const phonePresentation = getPhonePlaybackPresentation({
track,
playbackState,
currentTime,
duration,
});
const desktopPresentation = getDesktopPlaybackPresentation({
connection: desktopConnection,
connectionState: desktopConnectionState,
snapshot: desktopSnapshot,
});
const presentation = getEffectivePlaybackPresentation({
selectedTarget,
phone: phonePresentation,
desktop: desktopPresentation,
});
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const liveScopeActive = visible && scopeActive;
if (!presentation.visible) return null;
const isDesktop = presentation.target === 'desktop';
const isPlaying = presentation.playbackState === 'playing';
const isLoading = presentation.playbackState === 'loading';
const liveScopeActive = visible && scopeActive && !isDesktop;
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
const onTogglePlay = () => {
if (isDesktop) {
if (!desktopConnection || desktopConnectionState === 'error') {
void connectDesktop();
return;
}
void sendDesktopControl(isPlaying ? 'pause' : 'play');
return;
}
void togglePlay();
};
const onSkipNext = () => {
if (isDesktop) {
void sendDesktopControl('next');
return;
}
void skipToNext();
};
return (
<Pressable
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')}
onLayout={onLayout}
>
{liveScopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.25}
lineOpacity={0.38}
fillOpacity={0.3}
glow
glowOpacity={0.06}
<>
<Pressable
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')}
onLayout={onLayout}
>
{liveScopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.25}
lineOpacity={0.38}
fillOpacity={0.3}
glow
glowOpacity={0.06}
/>
</View>
)}
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={styles.row}>
<View style={styles.art}>
{presentation.artworkUri ? (
<Image source={{ uri: presentation.artworkUri }} style={styles.artImage} contentFit="cover" />
) : (
<AstraLogo size={20} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{presentation.title}
</Text>
<Text variant="label" numberOfLines={1}>
{presentation.subtitle}
</Text>
</View>
{isDesktop ? (
<Pressable
hitSlop={10}
onPress={() => setTargetPickerOpen(true)}
style={styles.control}
accessibilityLabel="Choose output device"
>
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} />
</Pressable>
) : null}
<Pressable hitSlop={10} onPress={onTogglePlay} style={styles.control}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={24}
color={colors.accent}
/>
</Pressable>
<Pressable hitSlop={10} onPress={onSkipNext} style={styles.control}>
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
</Pressable>
</View>
{presentation.hasTrack ? (
<MiniProgress
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
/>
</View>
)}
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={styles.row}>
<View style={styles.art}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
) : (
<AstraLogo size={20} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{track.title}
</Text>
<Text variant="label" numberOfLines={1}>
{track.artist}
</Text>
</View>
<Pressable hitSlop={10} onPress={togglePlay} style={styles.control}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={24}
color={colors.accent}
/>
</Pressable>
<Pressable hitSlop={10} onPress={skipToNext} style={styles.control}>
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
</Pressable>
</View>
<MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />
</Pressable>
) : null}
</Pressable>
<PlaybackTargetPicker
visible={targetPickerOpen}
onClose={() => setTargetPickerOpen(false)}
/>
</>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect } from 'react';
import {
Modal,
Pressable,
StyleSheet,
View
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore';
import {
desktopConnectionLabel,
hostFromBaseUrl,
} from '@/playback/playbackTargetPresentation';
interface PlaybackTargetPickerProps {
visible: boolean;
onClose: () => void;
}
export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const setTarget = usePlaybackTargetStore((s) => s.setTarget);
const phoneTrack = usePlayerStore((s) => s.currentTrack);
const connection = useDesktopRemoteStore((s) => s.connection);
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const snapshot = useDesktopRemoteStore((s) => s.snapshot);
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
const connectDesktop = useDesktopRemoteStore((s) => s.connect);
useEffect(() => {
if (visible) void initDesktopRemote();
}, [visible, initDesktopRemote]);
const choose = (target: PlaybackTarget) => {
void setTarget(target);
if (target === 'desktop' && connection && connectionState !== 'connected') {
void connectDesktop();
}
onClose();
};
const pairDesktop = () => {
onClose();
router.push('/desktop-remote' as never);
};
const desktopSubtitle = connection
? snapshot?.currentTrack?.title ||
snapshot?.outputDeviceLabel?.trim() ||
`${desktopConnectionLabel(connectionState)} · ${hostFromBaseUrl(connection.baseUrl)}`
: 'Pair with Astra Desktop on your LAN';
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable
style={[styles.sheet, { paddingBottom: insets.bottom + spacing.lg }]}
onPress={(event) => event.stopPropagation()}
>
<View style={styles.handle} />
<Text variant="label" color={colors.textTertiary} style={styles.eyebrow}>
OUTPUT DEVICE
</Text>
<TargetRow
icon="phone-portrait-outline"
title="This phone"
subtitle={phoneTrack ? phoneTrack.title : 'Local playback'}
selected={selectedTarget === 'phone'}
onPress={() => choose('phone')}
/>
{connection ? (
<TargetRow
icon="desktop-outline"
title={connection.desktopName ?? 'Astra Desktop'}
subtitle={desktopSubtitle}
selected={selectedTarget === 'desktop'}
onPress={() => choose('desktop')}
/>
) : (
<TargetRow
icon="add-circle-outline"
title="Pair Astra Desktop"
subtitle="Scan or enter a desktop pairing code"
selected={false}
onPress={pairDesktop}
/>
)}
</Pressable>
</Pressable>
</Modal>
);
}
function TargetRow({
icon,
title,
subtitle,
selected,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
subtitle: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<View style={[styles.iconWrap, selected && styles.iconWrapSelected]}>
<Ionicons name={icon} size={21} color={selected ? colors.accent : colors.textSecondary} />
</View>
<View style={styles.rowText}>
<Text variant="body" numberOfLines={1}>
{title}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{subtitle}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={22} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={22} color={colors.textTertiary} />
)}
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.58)',
},
sheet: {
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
backgroundColor: colors.bgSecondary,
paddingTop: spacing.sm,
paddingHorizontal: spacing.lg,
},
handle: {
alignSelf: 'center',
width: 44,
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.textTertiary,
marginBottom: spacing.lg,
},
eyebrow: {
letterSpacing: 1.5,
marginBottom: spacing.sm,
},
row: {
minHeight: 64,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
rowPressed: {
opacity: 0.65,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
iconWrapSelected: {
backgroundColor: colors.glassBg,
},
rowText: {
flex: 1,
minWidth: 0,
},
});
export default PlaybackTargetPicker;
+16 -3
View File
@@ -21,6 +21,9 @@ import {
spacing
} from '@/theme';
import { motion } from '@/theme/motion';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore';
type IconName = keyof typeof Ionicons.glyphMap;
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
@@ -54,6 +57,10 @@ export function TabBar({ items, onPress }: TabBarProps) {
const insets = useSafeAreaInsets();
const tabs = items.filter((item) => TAB_META[item.name]);
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const phoneTrack = usePlayerStore((s) => s.currentTrack);
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
const desktopTrack = useDesktopRemoteStore((s) => s.snapshot?.currentTrack);
const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused);
const count = tabs.length;
const activeIndex = Math.max(
@@ -79,11 +86,17 @@ export function TabBar({ items, onPress }: TabBarProps) {
return () => clearTimeout(timer);
}, [homeFocused, settledHomeFocused]);
const miniPlayerPhase: MiniPlayerPhase = homeFocused
? settledHomeFocused
const remoteMiniVisibleOnHome =
(selectedTarget === 'desktop' && Boolean(desktopConnection || desktopTrack)) ||
(!phoneTrack && Boolean(desktopTrack));
const suppressMiniForHome = homeFocused && !remoteMiniVisibleOnHome;
const suppressMiniForSettledHome = settledHomeFocused && !remoteMiniVisibleOnHome;
const miniPlayerPhase: MiniPlayerPhase = suppressMiniForHome
? suppressMiniForSettledHome
? 'hidden'
: 'reserved'
: settledHomeFocused
: suppressMiniForSettledHome
? 'hidden'
: 'visible';