mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
preliminary support for astra desktop
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user