mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
improve desktop remote
This commit is contained in:
+134
-61
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user