preliminary support for astra desktop

This commit is contained in:
Boof2015
2026-07-01 00:43:51 -04:00
parent 8f23c6a1e2
commit 76a5183dfb
25 changed files with 3145 additions and 0 deletions
+31
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -9,6 +10,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { LastFmStatus } from '@/types/lastFm';
@@ -72,6 +74,9 @@ export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -85,6 +90,14 @@ export default function SettingsScreen() {
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
useEffect(() => {
void initDesktopRemote();
}, [initDesktopRemote]);
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
return (
<Screen>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
@@ -202,6 +215,24 @@ export default function SettingsScreen() {
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
EXPERIMENTAL
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/desktop-remote' as never)}
accessibilityRole="button"
>
<Ionicons name="phone-portrait-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Desktop Remote</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{desktopRemoteSubtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text
variant="label"
color={colors.textTertiary}
+47
View File
@@ -23,8 +23,14 @@ import { useEQStore } from '@/stores/eqStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
import {
clearDesktopRemoteMediaSession,
setDesktopRemoteMediaSession,
subscribeDesktopRemoteMediaSessionCommands,
} from '@/services/desktopRemoteMediaSession';
import { colors } from '@/theme';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
@@ -61,6 +67,46 @@ function LastFmScrobbler() {
return null;
}
/** Mirrors Desktop Remote now-playing into a separate Android MediaSession. */
function DesktopRemoteMediaSessionSync() {
const connection = useDesktopRemoteStore((s) => s.connection);
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const snapshot = useDesktopRemoteStore((s) => s.snapshot);
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
useEffect(() => {
const subscription = subscribeDesktopRemoteMediaSessionCommands((event) => {
if (event.command === 'toggle-play') {
const playing = useDesktopRemoteStore.getState().snapshot?.playbackState === 'playing';
void useDesktopRemoteStore.getState().sendControl(playing ? 'pause' : 'play');
return;
}
if (event.command === 'stop') {
void useDesktopRemoteStore.getState().sendControl('pause');
return;
}
if (event.command === 'seek') {
void useDesktopRemoteStore.getState().sendControl('seek', event.position);
return;
}
void useDesktopRemoteStore.getState().sendControl(event.command);
});
return () => subscription.remove();
}, [sendControl]);
useEffect(() => {
if (!connection || connectionState === 'unpaired') {
clearDesktopRemoteMediaSession();
return;
}
setDesktopRemoteMediaSession(snapshot, connection);
}, [connection, connectionState, snapshot]);
useEffect(() => clearDesktopRemoteMediaSession, []);
return null;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
@@ -129,6 +175,7 @@ export default function RootLayout() {
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<DesktopRemoteMediaSessionSync />
<Stack
screenOptions={{
headerShown: false,
+983
View File
@@ -0,0 +1,983 @@
import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
useWindowDimensions,
View,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
import { MarqueeText } from '@/components/MarqueeText';
import { Screen } from '@/components/Screen';
import { SeekBar } from '@/components/SeekBar';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
const MAX_CONTENT_WIDTH = 408;
const CONTENT_SIDE_PADDING = spacing.lg;
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
const MEDIA_AREA_MIN = 220;
const COMPACT_MEDIA_AREA_MIN = 128;
const MEDIA_AREA_MAX = 360;
const ART_SIZE_MAX = 340;
const HEADER_HEIGHT = 32;
const CONTENT_TOP_PADDING = spacing.sm;
const CONTENT_BOTTOM_PADDING = spacing.lg;
const MEDIA_TOP_MARGIN = spacing.lg;
const MEDIA_BOTTOM_GAP = spacing.xl;
const TRACK_INFO_ESTIMATE = 96;
const SEEK_BLOCK_ESTIMATE = 54;
const PLAY_BUTTON_SIZE = 68;
const SKIP_ICON_SIZE = 32;
const PLAY_ICON_SIZE = 34;
const TRANSPORT_TOP_MARGIN = spacing.lg;
const SUB_BUTTON_SIZE = 40;
const SUB_ICON_SIZE = 20;
const SUB_TOP_MARGIN = spacing.lg;
const MIN_FLOATING_SPACE = spacing.sm;
interface RemoteLayout {
contentPadding: number;
contentWidth: number;
artSize: number;
mediaTopMargin: number;
mediaBottomGap: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLayout {
const contentPadding =
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
const mediaFloor = availableHeight < 620 ? COMPACT_MEDIA_AREA_MIN : MEDIA_AREA_MIN;
const mediaMin = Math.min(mediaMax, mediaFloor);
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
const fixedHeight =
CONTENT_TOP_PADDING +
CONTENT_BOTTOM_PADDING +
HEADER_HEIGHT +
mediaTopMargin +
mediaBottomGap +
TRACK_INFO_ESTIMATE +
SEEK_BLOCK_ESTIMATE +
TRANSPORT_TOP_MARGIN +
PLAY_BUTTON_SIZE +
SUB_TOP_MARGIN +
SUB_BUTTON_SIZE +
MIN_FLOATING_SPACE;
const heightBoundMedia = availableHeight - fixedHeight;
const fitAwareMediaMin = Math.min(mediaMin, Math.max(96, heightBoundMedia));
const artSize = Math.min(
Math.round(clamp(heightBoundMedia, fitAwareMediaMin, mediaMax)),
ART_SIZE_MAX
);
return {
contentPadding,
contentWidth,
artSize,
mediaTopMargin,
mediaBottomGap,
};
}
function formatPairingCountdown(expiresAt: number, now = Date.now()): string {
const seconds = Math.max(0, Math.ceil((expiresAt - now) / 1000));
const minutes = Math.floor(seconds / 60);
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
function hostFromBaseUrl(baseUrl: string): string {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
}
function connectionLabel(state: ReturnType<typeof useDesktopRemoteStore.getState>['connectionState']): string {
switch (state) {
case 'connected':
return 'Live';
case 'connecting':
return 'Connecting';
case 'reconnecting':
return 'Retrying';
case 'pinEntry':
return 'PIN';
case 'pendingApproval':
return 'Approval';
case 'pairing':
return 'Pairing';
case 'error':
return 'Offline';
default:
return 'Not paired';
}
}
function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
desktop: DesktopRemoteDiscoveredDesktop;
onPair: (desktop: DesktopRemoteDiscoveredDesktop) => void;
disabled: boolean;
}) {
return (
<Pressable
style={[styles.discoveredRow, disabled && styles.buttonDisabled]}
onPress={() => onPair(desktop)}
disabled={disabled}
>
<View style={styles.discoveredIcon}>
<Ionicons name="desktop-outline" size={20} color={colors.accent} />
</View>
<View style={styles.discoveredText}>
<Text variant="body" numberOfLines={1}>
{desktop.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{hostFromBaseUrl(desktop.baseUrl)}
</Text>
</View>
<View style={styles.discoveredAction}>
<Text variant="label" color={colors.accent}>
Pair
</Text>
<Ionicons name="keypad-outline" size={17} color={colors.accent} />
</View>
</Pressable>
);
}
export default function DesktopRemoteScreen() {
const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>();
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const initialized = useDesktopRemoteStore((s) => s.initialized);
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const connection = useDesktopRemoteStore((s) => s.connection);
const snapshot = useDesktopRemoteStore((s) => s.snapshot);
const discovered = useDesktopRemoteStore((s) => s.discovered);
const discoveryAvailable = useDesktopRemoteStore((s) => s.discoveryAvailable);
const discoveryRunning = useDesktopRemoteStore((s) => s.discoveryRunning);
const pairing = useDesktopRemoteStore((s) => s.pairing);
const pinPairing = useDesktopRemoteStore((s) => s.pinPairing);
const message = useDesktopRemoteStore((s) => s.message);
const errorMessage = useDesktopRemoteStore((s) => s.errorMessage);
const init = useDesktopRemoteStore((s) => s.init);
const startDiscovery = useDesktopRemoteStore((s) => s.startDiscovery);
const stopDiscovery = useDesktopRemoteStore((s) => s.stopDiscovery);
const requestPinPairing = useDesktopRemoteStore((s) => s.requestPinPairing);
const confirmPinPairing = useDesktopRemoteStore((s) => s.confirmPinPairing);
const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
const pairManual = useDesktopRemoteStore((s) => s.pairManual);
const reconnect = useDesktopRemoteStore((s) => s.reconnect);
const forget = useDesktopRemoteStore((s) => s.forget);
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
const [pairingLink, setPairingLink] = useState('');
const [pinInput, setPinInput] = useState('');
const [pinClock, setPinClock] = useState(() => Date.now());
const [manualBaseUrl, setManualBaseUrl] = useState('');
const [manualTicket, setManualTicket] = useState('');
useEffect(() => {
void init();
}, [init]);
useEffect(() => {
void startDiscovery();
return () => {
void stopDiscovery();
};
}, [startDiscovery, stopDiscovery]);
useEffect(() => {
if (typeof pair === 'string' && pair.trim()) {
void pairFromInput(pair);
router.setParams({ pair: undefined });
}
}, [pair, pairFromInput, router]);
useEffect(() => {
if (!pinPairing) return undefined;
const timer = setInterval(() => setPinClock(Date.now()), 1000);
return () => clearInterval(timer);
}, [pinPairing]);
const currentTrack = snapshot?.currentTrack ?? null;
const isPlaying = snapshot?.playbackState === 'playing';
const isBusy = connectionState === 'pairing' || connectionState === 'pendingApproval' || connectionState === 'connecting';
const art = currentTrack?.artworkDataUrl ?? null;
const accent = snapshot?.visualizerLineColor || colors.accent;
const availableHeight = windowHeight - insets.top - insets.bottom;
const remoteLayout = getRemoteLayout(windowWidth, availableHeight);
const remoteSource = connection?.desktopName ?? 'Astra Desktop';
const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : '');
const countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : '';
const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
const normalizedPinInput = pinInput.replace(/\s+/g, '');
const statusText = useMemo(() => {
if (message) return message;
if (connection) return hostFromBaseUrl(connection.baseUrl);
if (discoveryRunning) return 'Searching for Astra Desktop on this network.';
if (!discoveryAvailable) return 'Discovery unavailable on this device; use QR or manual pairing.';
return 'Not paired.';
}, [connection, discoveryAvailable, discoveryRunning, message]);
const confirmForget = () => {
Alert.alert('Forget desktop?', 'This removes the saved desktop pairing from this phone.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Forget', style: 'destructive', onPress: () => void forget() },
]);
};
const pairDiscovered = (desktop: DesktopRemoteDiscoveredDesktop) => {
setPinInput('');
setPinClock(Date.now());
void requestPinPairing(desktop.baseUrl);
};
const submitPin = () => {
void confirmPinPairing(pinInput);
};
const updatePinInput = (value: string) => {
setPinInput(value.replace(/\D/g, '').slice(0, 6));
};
const renderSetup = () => (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<Ionicons name="phone-portrait-outline" size={30} color={colors.accent} />
<View style={styles.heroText}>
<Text variant="title" style={styles.heading}>
Desktop Remote
</Text>
<Text variant="body" color={colors.textSecondary}>
Pair this phone with Astra Desktop to control playback over your LAN.
</Text>
</View>
</View>
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text variant="body">Nearby desktops</Text>
{discoveryRunning || isBusy ? <ActivityIndicator color={colors.accent} /> : null}
</View>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Tap a discovered desktop, then enter the PIN shown in Astra Desktop.
</Text>
{discoveryAvailable ? (
discovered.length > 0 ? (
<View style={styles.discoveredList}>
{discovered.map((desktop) => (
<DiscoveredDesktopRow
key={desktop.endpointUuid || desktop.baseUrl}
desktop={desktop}
onPair={pairDiscovered}
disabled={isBusy || pinPairingActive}
/>
))}
</View>
) : (
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Discovery is running. Use QR or manual pairing if this desktop does not appear.
</Text>
)
) : (
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Android LAN discovery is not available in this build. Use QR or manual pairing.
</Text>
)}
{pinPairing ? (
<View style={styles.pinPanel}>
<View style={styles.pinPanelHeader}>
<View>
<Text variant="body">{pinPairing.desktopName || 'Astra Desktop'}</Text>
<Text variant="caption" color={colors.textSecondary}>
{hostFromBaseUrl(pinPairing.baseUrl)}
</Text>
</View>
<Text variant="mono" color={colors.accentText}>
{pinCountdown}
</Text>
</View>
<TextInput
style={[styles.input, styles.pinInput]}
value={pinInput}
onChangeText={updatePinInput}
placeholder="000000"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
maxLength={6}
textContentType="oneTimeCode"
/>
<Pressable
style={[
styles.primaryButton,
(normalizedPinInput.length !== 6 || !pinPairingActive) && styles.buttonDisabled,
]}
disabled={normalizedPinInput.length !== 6 || isBusy || !pinPairingActive}
onPress={submitPin}
>
<Ionicons name="checkmark" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Confirm PIN
</Text>
</Pressable>
</View>
) : null}
</View>
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text variant="body">Pair with QR</Text>
{isBusy ? <ActivityIndicator color={colors.accent} /> : null}
</View>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Open Astra Desktop settings, enable Phone Remote, then scan or paste the pairing link.
</Text>
<View style={styles.actionRow}>
<Pressable style={styles.primaryButton} onPress={() => router.push('/desktop-remote/scan' as never)}>
<Ionicons name="scan" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Scan QR
</Text>
</Pressable>
</View>
<TextInput
style={styles.input}
value={pairingLink}
onChangeText={setPairingLink}
placeholder="Paste pairing link"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Pressable
style={[styles.secondaryButton, !pairingLink.trim() && styles.buttonDisabled]}
disabled={!pairingLink.trim()}
onPress={() => void pairFromInput(pairingLink)}
>
<Text variant="body" color={pairingLink.trim() ? colors.textPrimary : colors.textTertiary}>
Pair from link
</Text>
</Pressable>
</View>
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text variant="body">Manual fallback</Text>
</View>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Enter the desktop URL and pairing code from Astra Desktop.
</Text>
<TextInput
style={styles.input}
value={manualBaseUrl}
onChangeText={setManualBaseUrl}
placeholder="http://desktop-ip:38402"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={manualTicket}
onChangeText={setManualTicket}
placeholder="Pairing code"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable
style={[
styles.secondaryButton,
(!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled,
]}
disabled={!manualBaseUrl.trim() || !manualTicket.trim()}
onPress={() => void pairManual(manualBaseUrl, manualTicket)}
>
<Text
variant="body"
color={manualBaseUrl.trim() && manualTicket.trim() ? colors.textPrimary : colors.textTertiary}
>
Pair manually
</Text>
</Pressable>
</View>
{pinPairing ? (
<View style={styles.statusBox}>
<Text variant="body">Enter the PIN shown on desktop</Text>
<Text variant="mono" color={colors.accentText}>
{pinCountdown}
</Text>
</View>
) : pairing ? (
<View style={styles.statusBox}>
<Text variant="body">Waiting for desktop approval</Text>
<Text variant="mono" color={colors.accentText}>
{countdown}
</Text>
</View>
) : null}
{errorMessage ? (
<Text variant="caption" color={colors.warning} style={styles.feedback}>
{errorMessage}
</Text>
) : null}
</ScrollView>
);
const renderController = () => (
<View
style={[
styles.remoteContent,
{
paddingHorizontal: remoteLayout.contentPadding,
paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING,
},
]}
>
<View style={[styles.remoteShell, { width: remoteLayout.contentWidth }]}>
<View style={styles.remoteNowHeader}>
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable>
<View style={styles.headerMid}>
<Text variant="caption" style={styles.eyebrow}>
PLAYING FROM
</Text>
<Text variant="label" numberOfLines={1} style={styles.source}>
{remoteSource}
</Text>
</View>
<Pressable
style={styles.headerBtn}
onPress={() => void reconnect()}
hitSlop={12}
accessibilityLabel="Reconnect to desktop"
>
<Ionicons name="refresh" size={20} color={colors.textSecondary} />
</Pressable>
</View>
{currentTrack ? (
<View style={styles.remotePlayer}>
<View
style={[
styles.middleStack,
{
marginTop: remoteLayout.mediaTopMargin,
marginBottom: remoteLayout.mediaBottomGap,
},
]}
>
<View
style={[
styles.artCard,
{
width: remoteLayout.artSize,
height: remoteLayout.artSize,
},
]}
>
{art ? (
<Image
key={currentTrack.id}
source={{ uri: art }}
style={styles.artImage}
contentFit="cover"
/>
) : (
<AstraLogo size={Math.round(remoteLayout.artSize * 0.4)} />
)}
</View>
</View>
<View style={styles.spacer} />
<View style={styles.playerControls}>
<View style={styles.trackInfo}>
<View style={styles.trackTextStack}>
<MarqueeText
variant="heading"
containerStyle={styles.trackTitle}
style={styles.trackTitleText}
>
{currentTrack.title}
</MarqueeText>
<MarqueeText variant="body" style={styles.artist}>
{currentTrack.artist || currentTrack.album || remoteSource}
</MarqueeText>
</View>
</View>
<SeekBar
currentTime={snapshot?.currentTime ?? 0}
duration={snapshot?.duration ?? 0}
trackKey={currentTrack.id}
onSeek={(seconds) => void sendControl('seek', seconds)}
/>
<View style={styles.transport}>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
onPress={() => void reconnect()}
accessibilityLabel="Reconnect"
>
<Ionicons name="refresh" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
</Pressable>
<Pressable
onPress={() => void sendControl('previous')}
hitSlop={12}
style={styles.transportMainBtn}
accessibilityLabel="Previous"
>
<Ionicons name="play-skip-back" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
</Pressable>
<Pressable
onPress={() => void sendControl(isPlaying ? 'pause' : 'play')}
hitSlop={12}
style={[styles.playButton, { backgroundColor: accent }]}
accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
>
<Ionicons
name={snapshot?.playbackState === 'loading' ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={PLAY_ICON_SIZE}
color={colors.bgPrimary}
/>
</Pressable>
<Pressable
onPress={() => void sendControl('next')}
hitSlop={12}
style={styles.transportMainBtn}
accessibilityLabel="Next"
>
<Ionicons name="play-skip-forward" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
</Pressable>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
onPress={() => void sendControl('toggle-favorite')}
accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
accessibilityState={{ selected: currentTrack.isFavorite }}
>
<Ionicons
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
size={SUB_ICON_SIZE + 4}
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
</View>
<View style={styles.subRow}>
<View style={styles.statusPill}>
<View
style={[
styles.statusDot,
{ backgroundColor: connectionState === 'connected' ? accent : colors.warning },
]}
/>
<Text variant="label" color={colors.textSecondary}>
{connectionLabel(connectionState)}
</Text>
</View>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
{remoteDetail}
</Text>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={confirmForget}
accessibilityLabel="Forget desktop"
>
<Ionicons name="trash-outline" size={SUB_ICON_SIZE + 2} color={colors.warning} />
</Pressable>
</View>
</View>
</View>
) : (
<View style={styles.remoteEmpty}>
<AstraLogo size={72} />
<Text variant="heading" style={styles.emptyTitle}>
Nothing playing
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
{statusText}
</Text>
</View>
)}
</View>
{errorMessage ? (
<Text variant="caption" color={colors.warning} style={styles.remoteFeedback}>
{errorMessage}
</Text>
) : null}
</View>
);
const showController = Boolean(connection && connectionState !== 'unpaired');
return (
<Screen padded={!showController}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
{!initialized ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.accent} />
</View>
) : showController ? (
renderController()
) : (
<>
<View style={styles.topBar}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
</View>
{renderSetup()}
</>
)}
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
topBar: {
marginTop: spacing.md,
marginBottom: spacing.sm,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
content: {
paddingBottom: spacing.xxl,
gap: spacing.md,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
hero: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
marginTop: spacing.lg,
marginBottom: spacing.sm,
},
heroText: {
flex: 1,
},
heading: {
marginBottom: spacing.xs,
},
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.md,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardCopy: {
lineHeight: 19,
},
actionRow: {
flexDirection: 'row',
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing.sm,
},
secondaryButton: {
minHeight: 44,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing.sm,
},
buttonDisabled: {
opacity: 0.55,
},
input: {
minHeight: 46,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
paddingHorizontal: spacing.md,
color: colors.textPrimary,
fontSize: 15,
},
discoveredList: {
gap: spacing.sm,
},
discoveredRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 54,
},
discoveredIcon: {
width: 38,
height: 38,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
},
discoveredText: {
flex: 1,
},
discoveredAction: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
pinPanel: {
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
padding: spacing.md,
gap: spacing.md,
},
pinPanelHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: spacing.md,
},
pinInput: {
textAlign: 'center',
fontSize: 24,
fontWeight: '700',
},
statusBox: {
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
padding: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
feedback: {
lineHeight: 18,
},
remoteContent: {
flex: 1,
alignItems: 'center',
paddingTop: CONTENT_TOP_PADDING,
},
remoteShell: {
flex: 1,
},
remoteNowHeader: {
height: HEADER_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
headerMid: {
flex: 1,
alignItems: 'center',
},
eyebrow: {
color: colors.textTertiary,
letterSpacing: 0,
fontSize: 10,
},
source: {
color: colors.textSecondary,
marginTop: 1,
},
statusPill: {
height: 30,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
statusDot: {
width: 7,
height: 7,
borderRadius: 4,
},
remotePlayer: {
flex: 1,
},
middleStack: {
width: '100%',
alignItems: 'center',
},
artCard: {
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
spacer: {
flex: 1,
minHeight: MIN_FLOATING_SPACE,
},
playerControls: {
width: '100%',
},
trackInfo: {
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
marginBottom: spacing.md,
},
trackTextStack: {
flex: 1,
minWidth: 0,
alignItems: 'flex-start',
},
trackTitle: {
alignSelf: 'stretch',
},
trackTitleText: {
textAlign: 'left',
},
artist: {
color: colors.accentText,
},
transport: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: TRANSPORT_TOP_MARGIN,
},
transportMainBtn: {
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
transportSideBtn: {
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
playButton: {
width: PLAY_BUTTON_SIZE,
height: PLAY_BUTTON_SIZE,
borderRadius: radius.pill,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
subRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: SUB_TOP_MARGIN,
paddingHorizontal: spacing.sm,
},
subBtn: {
width: SUB_BUTTON_SIZE,
height: SUB_BUTTON_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
remoteDetail: {
flex: 1,
minWidth: 0,
},
remoteEmpty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
paddingHorizontal: spacing.xl,
},
emptyTitle: {
textAlign: 'center',
},
centered: {
textAlign: 'center',
},
remoteFeedback: {
alignSelf: 'center',
marginTop: spacing.sm,
paddingHorizontal: CONTENT_SIDE_PADDING,
textAlign: 'center',
},
});
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() {
const router = useRouter();
const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
const [permission, requestPermission] = useCameraPermissions();
const [locked, setLocked] = useState(false);
const onScanned = (result: BarcodeScanningResult) => {
if (locked) return;
const data = result.data?.trim();
if (!data) return;
setLocked(true);
void pairFromInput(data).finally(() => {
router.replace('/desktop-remote' as never);
});
};
return (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Desktop Remote
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Scan pairing QR
</Text>
{!permission ? (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
) : !permission.granted ? (
<View style={styles.permissionCard}>
<Ionicons name="camera-outline" size={28} color={colors.accent} />
<Text variant="body">Camera access is needed to scan the desktop pairing QR.</Text>
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
</View>
) : (
<View style={styles.scannerFrame}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={locked ? undefined : onScanned}
/>
<View pointerEvents="none" style={styles.scanBox} />
{locked ? (
<View style={styles.locked}>
<ActivityIndicator color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Pairing...
</Text>
</View>
) : null}
</View>
)}
</Screen>
);
}
const styles = StyleSheet.create({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
heading: {
marginBottom: spacing.lg,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
permissionCard: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.md,
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
scannerFrame: {
flex: 1,
borderRadius: radius.md,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
marginBottom: spacing.xl,
},
camera: {
flex: 1,
},
scanBox: {
position: 'absolute',
left: '15%',
right: '15%',
top: '25%',
aspectRatio: 1,
borderRadius: radius.md,
borderWidth: 2,
borderColor: colors.accent,
},
locked: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.lg,
minHeight: 48,
borderRadius: radius.sm,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing.sm,
},
});
+325
View File
@@ -0,0 +1,325 @@
import * as Device from 'expo-device';
import type {
DesktopRemoteControlCommand,
DesktopRemoteIdentity,
DesktopRemoteNowPlayingSnapshot,
DesktopRemotePairingClaim,
DesktopRemotePairingStatus,
DesktopRemotePinPairingRequest,
} from '@/types/desktopRemote';
export {
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
} from './desktopRemotePairing';
const REQUEST_TIMEOUT_MS = 8000;
interface JsonRequestOptions {
method?: 'GET' | 'POST';
token?: string | null;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
export class DesktopRemoteHttpError extends Error {
status: number;
payload: unknown;
constructor(status: number, message: string, payload: unknown) {
super(message);
this.status = status;
this.payload = payload;
}
}
function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const abort = () => controller.abort();
if (parentSignal) {
if (parentSignal.aborted) controller.abort();
else parentSignal.addEventListener('abort', abort, { once: true });
}
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
},
{ once: true }
);
return controller.signal;
}
async function fetchJson<T>(
baseUrl: string,
path: string,
options: JsonRequestOptions = {}
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
let body: string | undefined;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json; charset=utf-8';
body = JSON.stringify(options.body);
}
if (options.token) headers.Authorization = `Bearer ${options.token}`;
const response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? (body ? 'POST' : 'GET'),
headers,
body,
cache: 'no-store',
signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
: `Desktop remote request failed (${response.status}).`;
throw new DesktopRemoteHttpError(response.status, message, payload);
}
return payload as T;
}
function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null {
if (!payload || typeof payload !== 'object') return null;
const candidate = payload as Record<string, unknown>;
return {
endpointUuid: typeof candidate.endpointUuid === 'string' && candidate.endpointUuid.trim()
? candidate.endpointUuid.trim()
: null,
desktopName: typeof candidate.desktopName === 'string' && candidate.desktopName.trim()
? candidate.desktopName.trim()
: null,
protocolVersion:
typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
? candidate.protocolVersion
: 1,
};
}
function clientLabel(): string {
if (Device.osName === 'Android') return 'Android Phone';
if (Device.osName === 'iOS') return Device.modelName?.includes('iPad') ? 'iPad' : 'iPhone';
return 'Astra Mobile';
}
export function defaultDesktopRemoteDeviceName(): string {
const model = Device.modelName?.trim();
return model ? `${model} Remote` : 'Astra Mobile Remote';
}
export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<DesktopRemoteIdentity | null> {
try {
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity');
return normalizeIdentity(payload);
} catch {
return null;
}
}
export async function claimDesktopRemotePairingTicket(
baseUrl: string,
ticket: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePairingClaim> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/claim', {
method: 'POST',
body: {
ticket,
deviceName,
clientLabel: clientLabel(),
},
});
return {
requestId: String(payload.requestId ?? ''),
pollToken: String(payload.pollToken ?? ''),
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
deviceName: String(payload.deviceName ?? deviceName),
clientLabel: String(payload.clientLabel ?? clientLabel()),
identity: normalizeIdentity(payload.identity ?? payload),
};
}
export async function requestDesktopRemotePinPairing(
baseUrl: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePinPairingRequest> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-request', {
method: 'POST',
body: {
deviceName,
clientLabel: clientLabel(),
},
});
return {
requestId: String(payload.requestId ?? ''),
pollToken: String(payload.pollToken ?? ''),
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
deviceName: String(payload.deviceName ?? deviceName),
clientLabel: String(payload.clientLabel ?? clientLabel()),
identity: normalizeIdentity(payload.identity ?? payload),
};
}
export async function confirmDesktopRemotePinPairing(
baseUrl: string,
requestId: string,
pin: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-confirm', {
method: 'POST',
body: {
requestId,
pin,
},
});
const state = typeof payload.state === 'string' ? payload.state : 'approved';
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
}
export async function fetchDesktopRemotePairingStatus(
baseUrl: string,
pollToken: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(
baseUrl,
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
);
const state = typeof payload.state === 'string' ? payload.state : 'pending';
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
}
export async function fetchDesktopRemoteNowPlaying(
baseUrl: string,
token: string,
inlineArtwork = false
): Promise<DesktopRemoteNowPlayingSnapshot> {
return fetchJson<DesktopRemoteNowPlayingSnapshot>(
baseUrl,
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
{ token }
);
}
export async function sendDesktopRemoteControl(
baseUrl: string,
token: string,
command: DesktopRemoteControlCommand,
time?: number
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
body: command === 'seek' ? { command, time } : { command },
});
}
export type DesktopRemoteSseHandlers = {
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void;
onUnauthorized: () => void;
onDisconnect: () => void;
onError?: (error: unknown) => void;
};
function processSseChunk(
buffer: { value: string },
chunk: string,
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void
): void {
buffer.value += chunk.replace(/\r/g, '');
let boundary = buffer.value.indexOf('\n\n');
while (boundary !== -1) {
const raw = buffer.value.slice(0, boundary);
buffer.value = buffer.value.slice(boundary + 2);
let eventName = 'message';
const data: string[] = [];
for (const line of raw.split('\n')) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (eventName === 'now-playing' && data.length > 0) {
try {
onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot);
} catch {
// Ignore a malformed event; polling/reconnect will correct the UI.
}
}
boundary = buffer.value.indexOf('\n\n');
}
}
export function startDesktopRemoteEventStream(
baseUrl: string,
token: string,
handlers: DesktopRemoteSseHandlers
): () => void {
const controller = new AbortController();
let closed = false;
void (async () => {
try {
const response = await fetch(`${baseUrl}/v1/events`, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
signal: controller.signal,
});
if (response.status === 401) {
handlers.onUnauthorized();
return;
}
const body = response.body as unknown as {
getReader?: () => {
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
};
} | null;
if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`);
const reader = body.getReader();
const decoder = new TextDecoder();
const buffer = { value: '' };
while (!closed) {
const next = await reader.read();
if (next.done) break;
if (next.value) processSseChunk(buffer, decoder.decode(next.value, { stream: true }), handlers.onSnapshot);
}
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot);
if (!closed) handlers.onDisconnect();
} catch (error) {
if (closed || controller.signal.aborted) return;
handlers.onError?.(error);
handlers.onDisconnect();
}
})();
return () => {
closed = true;
controller.abort();
};
}
+56
View File
@@ -0,0 +1,56 @@
import * as SecureStore from 'expo-secure-store';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
const CONNECTION_KEY = 'desktop_remote_connection_v1';
const TOKEN_KEY = 'desktop_remote_token_v1';
export async function getDesktopRemoteConnection(): Promise<DesktopRemoteConnection | null> {
const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DesktopRemoteConnection>;
if (!parsed || typeof parsed !== 'object') return null;
if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null;
return {
id: parsed.id,
baseUrl: parsed.baseUrl,
endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null,
desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null,
protocolVersion:
typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion)
? parsed.protocolVersion
: 1,
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
pairedAt:
typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
? parsed.pairedAt
: Date.now(),
lastConnectedAt:
typeof parsed.lastConnectedAt === 'number' && Number.isFinite(parsed.lastConnectedAt)
? parsed.lastConnectedAt
: null,
};
} catch {
return null;
}
}
export async function setDesktopRemoteConnection(connection: DesktopRemoteConnection): Promise<void> {
await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
}
export async function getDesktopRemoteToken(): Promise<string | null> {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
return token && token.trim() ? token.trim() : null;
}
export async function setDesktopRemoteToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function clearDesktopRemotePairing(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(CONNECTION_KEY),
SecureStore.deleteItemAsync(TOKEN_KEY),
]);
}
+26
View File
@@ -0,0 +1,26 @@
import { Platform } from 'react-native';
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
type DiscoveryEvents = {
onDesktopRemoteFound: (desktop: DesktopRemoteDiscoveredDesktop) => void;
onDesktopRemoteLost: (event: { name: string }) => void;
};
declare class AstraDesktopDiscoveryModuleType extends NativeModule<DiscoveryEvents> {
start(): Promise<void>;
stop(): Promise<void>;
getCached(): DesktopRemoteDiscoveredDesktop[];
}
const native = requireOptionalNativeModule<AstraDesktopDiscoveryModuleType>('AstraDesktopDiscovery');
export const desktopRemoteDiscoveryAvailable = Platform.OS === 'android' && native != null;
export const AstraDesktopDiscovery = native ?? {
addListener: () => ({ remove: () => {} }),
removeAllListeners: () => {},
start: async () => {},
stop: async () => {},
getCached: () => [],
};
+42
View File
@@ -0,0 +1,42 @@
import {
AstraDesktopRemoteSession,
type AstraDesktopRemoteSessionCommand,
} from '../../modules/astra-desktop-remote-session';
import type {
DesktopRemoteConnection,
DesktopRemoteNowPlayingSnapshot,
} from '@/types/desktopRemote';
export function setDesktopRemoteMediaSession(
snapshot: DesktopRemoteNowPlayingSnapshot | null,
connection: DesktopRemoteConnection | null
): void {
if (!snapshot?.currentTrack || !connection) {
AstraDesktopRemoteSession.clear();
return;
}
const track = snapshot.currentTrack;
AstraDesktopRemoteSession.setNowPlaying({
title: track.title,
artist: track.artist,
album: track.album,
desktopName: connection.desktopName,
artworkDataUrl: track.artworkDataUrl,
playbackState: snapshot.playbackState,
hasTrack: true,
duration: snapshot.duration,
position: snapshot.currentTime,
updatedAt: snapshot.updatedAt,
isFavorite: track.isFavorite,
});
}
export function clearDesktopRemoteMediaSession(): void {
AstraDesktopRemoteSession.clear();
}
export function subscribeDesktopRemoteMediaSessionCommands(
handler: (command: AstraDesktopRemoteSessionCommand) => void
): { remove: () => void } {
return AstraDesktopRemoteSession.addListener('onDesktopRemoteCommand', handler);
}
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeDesktopRemotePinInput,
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
} from './desktopRemotePairing.ts';
test('parses current PWA pairing URL format', () => {
assert.deepEqual(
parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
{
baseUrl: 'http://192.168.1.20:38402',
ticket: 'abcDEF_1234567890',
}
);
});
test('parses native pairing links without accepting missing base URLs', () => {
assert.deepEqual(
parseDesktopRemotePairingInput(
'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
),
{
baseUrl: 'http://10.0.0.8:38402',
ticket: 'abcDEF_1234567890',
}
);
assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null);
});
test('manual pairing requires a reachable http base URL and ticket-shaped code', () => {
assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), {
baseUrl: 'http://desktop.local:38402',
ticket: 'abcDEF_1234567890',
});
assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
});
test('PIN pairing accepts only six digits with optional spacing', () => {
assert.equal(normalizeDesktopRemotePinInput('123456'), '123456');
assert.equal(normalizeDesktopRemotePinInput('123 456'), '123456');
assert.equal(normalizeDesktopRemotePinInput('12345'), null);
assert.equal(normalizeDesktopRemotePinInput('12345x'), null);
});
+55
View File
@@ -0,0 +1,55 @@
import type { DesktopRemotePairingInput } from '@/types/desktopRemote';
const PAIRING_TICKET_PATTERN = /^[A-Za-z0-9_-]{16,}$/;
const PAIRING_PIN_PATTERN = /^\d{6}$/;
function normalizeBaseUrl(value: string): string | null {
const trimmed = value.trim().replace(/\/+$/, '');
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}
function extractPairFromUrl(url: URL): string {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
const hashTicket = hashParams.get('pair')?.trim();
if (hashTicket) return hashTicket;
return url.searchParams.get('pair')?.trim() ?? '';
}
export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemotePairingInput | null {
const input = rawInput.trim();
if (!input) return null;
try {
const parsed = new URL(input);
if (parsed.protocol === 'astra:') {
const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? '');
const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? '';
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
}
const ticket = extractPairFromUrl(parsed);
const baseUrl = normalizeBaseUrl(parsed.origin);
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
} catch {
return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
}
}
export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedTicket = ticket.trim();
if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket };
}
export function normalizeDesktopRemotePinInput(pin: string): string | null {
const normalizedPin = pin.replace(/\s+/g, '');
return PAIRING_PIN_PATTERN.test(normalizedPin) ? normalizedPin : null;
}
+622
View File
@@ -0,0 +1,622 @@
import { create } from 'zustand';
import {
AstraDesktopDiscovery,
desktopRemoteDiscoveryAvailable,
} from '@/services/desktopRemoteDiscovery';
import {
DesktopRemoteHttpError,
claimDesktopRemotePairingTicket,
confirmDesktopRemotePinPairing,
defaultDesktopRemoteDeviceName,
fetchDesktopRemoteIdentity,
fetchDesktopRemoteNowPlaying,
fetchDesktopRemotePairingStatus,
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
requestDesktopRemotePinPairing,
sendDesktopRemoteControl,
startDesktopRemoteEventStream,
} from '@/services/desktopRemoteClient';
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
import {
clearDesktopRemotePairing,
getDesktopRemoteConnection,
getDesktopRemoteToken,
setDesktopRemoteConnection,
setDesktopRemoteToken,
} from '@/services/desktopRemoteCredentials';
import type {
DesktopRemoteConnection,
DesktopRemoteControlCommand,
DesktopRemoteDiscoveredDesktop,
DesktopRemoteIdentity,
DesktopRemoteNowPlayingSnapshot,
} from '@/types/desktopRemote';
const PAIR_POLL_INTERVAL_MS = 1500;
const SNAPSHOT_POLL_INTERVAL_MS = 5000;
const RECONNECT_DELAY_MS = 2000;
export type DesktopRemoteConnectionState =
| 'unpaired'
| 'pairing'
| 'pinEntry'
| 'pendingApproval'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'error';
interface PairingAttempt {
baseUrl: string;
pollToken: string;
expiresAt: number;
}
interface PinPairingAttempt {
baseUrl: string;
requestId: string;
expiresAt: number;
desktopName: string | null;
}
interface DesktopRemoteStore {
initialized: boolean;
connectionState: DesktopRemoteConnectionState;
connection: DesktopRemoteConnection | null;
token: string | null;
snapshot: DesktopRemoteNowPlayingSnapshot | null;
discovered: DesktopRemoteDiscoveredDesktop[];
discoveryAvailable: boolean;
discoveryRunning: boolean;
pairing: PairingAttempt | null;
pinPairing: PinPairingAttempt | null;
message: string;
errorMessage: string;
init: () => Promise<void>;
startDiscovery: () => Promise<void>;
stopDiscovery: () => Promise<void>;
requestPinPairing: (baseUrl: string) => Promise<void>;
confirmPinPairing: (pin: string) => Promise<void>;
pairFromInput: (input: string) => Promise<void>;
pairManual: (baseUrl: string, ticket: string) => Promise<void>;
connect: () => Promise<boolean>;
reconnect: () => Promise<void>;
disconnect: () => void;
forget: () => Promise<void>;
sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise<void>;
}
let pairingPollTimer: ReturnType<typeof setTimeout> | null = null;
let snapshotPollTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let stopEventStream: (() => void) | null = null;
let discoverySubscriptions: { remove: () => void }[] = [];
let inlineArtworkRequestKey: string | null = null;
function clearPairingPoll(): void {
if (pairingPollTimer !== null) {
clearTimeout(pairingPollTimer);
pairingPollTimer = null;
}
}
function clearSnapshotPoll(): void {
if (snapshotPollTimer !== null) {
clearInterval(snapshotPollTimer);
snapshotPollTimer = null;
}
}
function clearReconnect(): void {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function stopRealtime(): void {
stopEventStream?.();
stopEventStream = null;
clearSnapshotPoll();
clearReconnect();
}
function displayName(identity: DesktopRemoteIdentity | null, baseUrl: string): string {
return identity?.desktopName?.trim() || new URL(baseUrl).hostname || 'Astra Desktop';
}
function stableConnectionId(identity: DesktopRemoteIdentity | null, baseUrl: string): string {
return identity?.endpointUuid?.trim() || baseUrl;
}
function errorMessage(error: unknown): string {
if (error instanceof DesktopRemoteHttpError) return error.message;
if (error instanceof Error && error.message.trim()) return error.message;
return 'Desktop remote request failed.';
}
function mergeSnapshotArtwork(
previous: DesktopRemoteNowPlayingSnapshot | null,
next: DesktopRemoteNowPlayingSnapshot
): DesktopRemoteNowPlayingSnapshot {
const previousTrack = previous?.currentTrack ?? null;
const nextTrack = next.currentTrack ?? null;
if (!previousTrack || !nextTrack) return next;
if (previousTrack.id !== nextTrack.id) return next;
if (nextTrack.artworkDataUrl || !previousTrack.artworkDataUrl) return next;
return {
...next,
currentTrack: {
...nextTrack,
artworkDataUrl: previousTrack.artworkDataUrl,
},
};
}
async function persistConnectedDesktop(
baseUrl: string,
token: string,
deviceId: string | null,
identity: DesktopRemoteIdentity | null
): Promise<DesktopRemoteConnection> {
const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl));
const now = Date.now();
const connection: DesktopRemoteConnection = {
id: stableConnectionId(resolvedIdentity, baseUrl),
baseUrl,
endpointUuid: resolvedIdentity?.endpointUuid ?? null,
desktopName: displayName(resolvedIdentity, baseUrl),
protocolVersion: resolvedIdentity?.protocolVersion ?? 1,
deviceId,
pairedAt: now,
lastConnectedAt: now,
};
await Promise.all([
setDesktopRemoteConnection(connection),
setDesktopRemoteToken(token),
]);
return connection;
}
export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const refreshInlineArtwork = () => {
const { connection, token, snapshot } = get();
const track = snapshot?.currentTrack ?? null;
if (!connection || !token || !track || track.artworkDataUrl) return;
const requestKey = `${connection.id}:${track.id}`;
if (inlineArtworkRequestKey === requestKey) return;
inlineArtworkRequestKey = requestKey;
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true).then(
(inlineSnapshot) => {
inlineArtworkRequestKey = null;
set((state) => ({
snapshot: mergeSnapshotArtwork(state.snapshot, inlineSnapshot),
connectionState: 'connected',
errorMessage: '',
}));
},
() => {
inlineArtworkRequestKey = null;
}
);
};
const scheduleSnapshotPoll = () => {
clearSnapshotPoll();
snapshotPollTimer = setInterval(() => {
const { connection, token, connectionState } = get();
if (!connection || !token || connectionState === 'connecting') return;
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
(snapshot) => {
set((state) => ({
snapshot: mergeSnapshotArtwork(state.snapshot, snapshot),
connectionState: 'connected',
errorMessage: '',
}));
refreshInlineArtwork();
},
(error) => {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
void get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
return;
}
if (get().connectionState === 'connected') {
set({ connectionState: 'reconnecting', message: 'Reconnecting to desktop...' });
}
}
);
}, SNAPSHOT_POLL_INTERVAL_MS);
};
const scheduleReconnect = () => {
clearReconnect();
const { connection, token } = get();
if (!connection || !token) return;
set({ connectionState: 'reconnecting', message: 'Reconnecting to desktop...' });
scheduleSnapshotPoll();
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void get().connect();
}, RECONNECT_DELAY_MS);
};
const pollPairingStatus = async () => {
const pairing = get().pairing;
if (!pairing) return;
try {
const status = await fetchDesktopRemotePairingStatus(pairing.baseUrl, pairing.pollToken);
if (status.state === 'approved' && status.token?.trim()) {
clearPairingPoll();
const connection = await persistConnectedDesktop(
pairing.baseUrl,
status.token.trim(),
status.deviceId ?? null,
status.identity ?? null
);
set({
connection,
token: status.token.trim(),
pairing: null,
pinPairing: null,
connectionState: 'connecting',
message: 'Paired. Connecting...',
errorMessage: '',
});
void get().connect();
return;
}
if (status.state === 'rejected') {
clearPairingPoll();
set({
pairing: null,
connectionState: 'error',
message: '',
errorMessage: 'Desktop rejected this pairing request.',
});
return;
}
if (status.state === 'expired' || status.state === 'consumed') {
clearPairingPoll();
set({
pairing: null,
connectionState: 'error',
message: '',
errorMessage: 'Pairing link expired. Generate a new QR code on desktop.',
});
return;
}
set({
connectionState: 'pendingApproval',
pairing: { ...pairing, expiresAt: status.expiresAt || pairing.expiresAt },
message: 'Approve this phone in Astra on desktop.',
});
pairingPollTimer = setTimeout(() => void pollPairingStatus(), PAIR_POLL_INTERVAL_MS);
} catch (error) {
clearPairingPoll();
set({
pairing: null,
connectionState: 'error',
message: '',
errorMessage: errorMessage(error),
});
}
};
const claimPairing = async (baseUrl: string, ticket: string) => {
clearPairingPoll();
stopRealtime();
set({
connectionState: 'pairing',
pairing: null,
pinPairing: null,
snapshot: null,
message: 'Starting pairing...',
errorMessage: '',
});
try {
const claim = await claimDesktopRemotePairingTicket(
baseUrl,
ticket,
defaultDesktopRemoteDeviceName()
);
if (!claim.pollToken) throw new Error('Desktop did not return a pairing poll token.');
set({
connectionState: 'pendingApproval',
pairing: {
baseUrl,
pollToken: claim.pollToken,
expiresAt: claim.expiresAt,
},
message: 'Approve this phone in Astra on desktop.',
});
await pollPairingStatus();
} catch (error) {
set({
connectionState: 'error',
pairing: null,
pinPairing: null,
message: '',
errorMessage: errorMessage(error),
});
}
};
const requestPinPairing = async (baseUrl: string) => {
clearPairingPoll();
stopRealtime();
set({
connectionState: 'pairing',
pairing: null,
pinPairing: null,
snapshot: null,
message: 'Requesting PIN from desktop...',
errorMessage: '',
});
try {
const request = await requestDesktopRemotePinPairing(baseUrl, defaultDesktopRemoteDeviceName());
if (!request.requestId) throw new Error('Desktop did not return a PIN pairing request.');
const desktopName = request.identity?.desktopName?.trim() || null;
set({
connectionState: 'pinEntry',
pinPairing: {
baseUrl,
requestId: request.requestId,
expiresAt: request.expiresAt,
desktopName,
},
message: `Enter the PIN shown on ${desktopName || 'Astra Desktop'}.`,
errorMessage: '',
});
} catch (error) {
set({
connectionState: 'error',
pinPairing: null,
message: '',
errorMessage: errorMessage(error),
});
}
};
const confirmPinPairing = async (pin: string) => {
const normalizedPin = normalizeDesktopRemotePinInput(pin);
const attempt = get().pinPairing;
if (!attempt) return;
if (!normalizedPin) {
set({ errorMessage: 'Enter the 6-digit PIN shown on desktop.' });
return;
}
set({ connectionState: 'pairing', message: 'Confirming PIN...', errorMessage: '' });
try {
const status = await confirmDesktopRemotePinPairing(attempt.baseUrl, attempt.requestId, normalizedPin);
if (status.state !== 'approved' || !status.token?.trim()) {
throw new Error('Desktop did not approve this PIN pairing.');
}
const connection = await persistConnectedDesktop(
attempt.baseUrl,
status.token.trim(),
status.deviceId ?? null,
status.identity ?? null
);
set({
connection,
token: status.token.trim(),
pairing: null,
pinPairing: null,
connectionState: 'connecting',
message: 'Paired. Connecting...',
errorMessage: '',
});
void get().connect();
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
set({
connectionState: 'pinEntry',
message: `Enter the PIN shown on ${attempt.desktopName || 'Astra Desktop'}.`,
errorMessage: 'Wrong PIN. Try again.',
});
return;
}
set({
connectionState: 'error',
pinPairing: null,
message: '',
errorMessage: errorMessage(error),
});
}
};
return {
initialized: false,
connectionState: 'unpaired',
connection: null,
token: null,
snapshot: null,
discovered: [],
discoveryAvailable: desktopRemoteDiscoveryAvailable,
discoveryRunning: false,
pairing: null,
pinPairing: null,
message: '',
errorMessage: '',
init: async () => {
if (get().initialized) return;
const [connection, token] = await Promise.all([
getDesktopRemoteConnection(),
getDesktopRemoteToken(),
]);
set({
initialized: true,
connection,
token,
connectionState: connection && token ? 'connecting' : 'unpaired',
});
if (connection && token) void get().connect();
},
startDiscovery: async () => {
if (!desktopRemoteDiscoveryAvailable || get().discoveryRunning) return;
if (discoverySubscriptions.length === 0) {
discoverySubscriptions = [
AstraDesktopDiscovery.addListener('onDesktopRemoteFound', (desktop) => {
set((state) => {
const byKey = new Map(state.discovered.map((item) => [item.endpointUuid || item.baseUrl, item]));
byKey.set(desktop.endpointUuid || desktop.baseUrl, desktop);
return {
discovered: Array.from(byKey.values()).sort((left, right) =>
left.name.localeCompare(right.name)
),
};
});
}),
AstraDesktopDiscovery.addListener('onDesktopRemoteLost', (event) => {
set((state) => ({
discovered: state.discovered.filter((item) => item.name !== event.name),
}));
}),
];
}
set({ discoveryRunning: true, discovered: AstraDesktopDiscovery.getCached() });
await AstraDesktopDiscovery.start();
},
stopDiscovery: async () => {
if (!get().discoveryRunning) return;
await AstraDesktopDiscovery.stop();
set({ discoveryRunning: false });
},
requestPinPairing,
confirmPinPairing,
pairFromInput: async (input: string) => {
const parsed = parseDesktopRemotePairingInput(input);
if (!parsed || !parsed.baseUrl) {
set({
connectionState: 'error',
errorMessage: 'Paste or scan a full desktop pairing link.',
});
return;
}
await claimPairing(parsed.baseUrl, parsed.ticket);
},
pairManual: async (baseUrl: string, ticket: string) => {
const parsed = parseDesktopRemoteManualInput(baseUrl, ticket);
if (!parsed) {
set({
connectionState: 'error',
errorMessage: 'Enter a valid desktop URL and pairing code.',
});
return;
}
await claimPairing(parsed.baseUrl, parsed.ticket);
},
connect: async () => {
const { connection, token } = get();
if (!connection || !token) {
set({ connectionState: 'unpaired' });
return false;
}
stopRealtime();
set({ connectionState: 'connecting', message: 'Connecting to desktop...', errorMessage: '' });
try {
const snapshot = await fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true);
const nextConnection = { ...connection, lastConnectedAt: Date.now() };
await setDesktopRemoteConnection(nextConnection);
set({
connection: nextConnection,
snapshot,
connectionState: 'connected',
message: '',
errorMessage: '',
});
stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
onSnapshot: (nextSnapshot) => {
set((state) => ({
snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot),
connectionState: 'connected',
message: '',
errorMessage: '',
}));
refreshInlineArtwork();
},
onUnauthorized: () => {
void get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
},
onDisconnect: scheduleReconnect,
onError: () => {
scheduleSnapshotPoll();
},
});
scheduleSnapshotPoll();
return true;
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
return false;
}
set({
connectionState: 'error',
message: '',
errorMessage: errorMessage(error),
});
scheduleReconnect();
return false;
}
},
reconnect: async () => {
await get().connect();
},
disconnect: () => {
stopRealtime();
clearPairingPoll();
set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, pinPairing: null });
},
forget: async () => {
stopRealtime();
clearPairingPoll();
await clearDesktopRemotePairing();
set({
connectionState: 'unpaired',
connection: null,
token: null,
snapshot: null,
pairing: null,
pinPairing: null,
message: '',
});
},
sendControl: async (command, time) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
await sendDesktopRemoteControl(connection.baseUrl, token, command, time);
set({ errorMessage: '' });
if (command === 'seek' && typeof time === 'number') {
set((state) => state.snapshot
? { snapshot: { ...state.snapshot, currentTime: time, updatedAt: Date.now() } }
: {});
}
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
return;
}
set({ errorMessage: errorMessage(error) });
}
},
};
});
+86
View File
@@ -0,0 +1,86 @@
export const DESKTOP_REMOTE_PROTOCOL_VERSION = 1;
export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
export type DesktopRemoteControlCommand =
| 'play'
| 'pause'
| 'next'
| 'previous'
| 'toggle-favorite'
| 'seek';
export interface DesktopRemoteIdentity {
endpointUuid: string | null;
desktopName: string | null;
protocolVersion: number;
}
export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
id: string;
baseUrl: string;
deviceId: string | null;
pairedAt: number;
lastConnectedAt: number | null;
}
export interface DesktopRemoteTrackSnapshot {
id: string;
title: string;
artist: string;
artists: string[];
album: string;
albumArtists: string[];
isFavorite: boolean;
artworkUrl: string | null;
artworkDataUrl: string | null;
}
export interface DesktopRemoteNowPlayingSnapshot {
playbackState: DesktopRemotePlaybackState;
currentTime: number;
duration: number;
queueLength: number;
outputDeviceLabel: string | null;
visualizerLineColor: string;
currentTrack: DesktopRemoteTrackSnapshot | null;
updatedAt: number;
}
export interface DesktopRemotePairingClaim {
requestId: string;
pollToken: string;
expiresAt: number;
deviceName: string;
clientLabel: string;
identity: DesktopRemoteIdentity | null;
}
export type DesktopRemotePinPairingRequest = DesktopRemotePairingClaim;
export type DesktopRemotePairingState =
| 'pending'
| 'approved'
| 'rejected'
| 'expired'
| 'consumed';
export interface DesktopRemotePairingStatus {
state: DesktopRemotePairingState;
expiresAt: number;
token?: string;
deviceId?: string | null;
identity?: DesktopRemoteIdentity | null;
}
export interface DesktopRemotePairingInput {
baseUrl: string;
ticket: string;
}
export interface DesktopRemoteDiscoveredDesktop extends DesktopRemoteIdentity {
name: string;
baseUrl: string;
address: string;
port: number;
lastSeenAt: number;
}