desktop sync support

This commit is contained in:
Boof2015
2026-07-06 14:14:41 -04:00
parent 006e55422b
commit 2b19f51a49
26 changed files with 4183 additions and 208 deletions
+32
View File
@@ -21,6 +21,8 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { formatRelativeTime } from '@/lib/format';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { LastFmStatus } from '@/types/lastFm';
@@ -253,6 +255,18 @@ export default function SettingsScreen() {
return () => task.cancel();
}, [initDesktopRemote]);
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
: desktopSyncConflictCount > 0
? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve`
: desktopSyncStatus === 'syncing'
? 'Syncing…'
: desktopLastSyncAt !== null
? `Synced ${formatRelativeTime(desktopLastSyncAt)}`
: `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`;
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
@@ -403,6 +417,24 @@ export default function SettingsScreen() {
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Pressable
style={styles.option}
onPress={() => router.push('/desktop-sync' as never)}
accessibilityRole="button"
>
<Ionicons name="sync-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Desktop Sync</Text>
<Text
variant="caption"
color={desktopSyncConflictCount > 0 ? colors.warning : colors.textSecondary}
style={styles.optionDescription}
>
{desktopSyncSubtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text
variant="label"
+117
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { AppState } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -31,6 +32,13 @@ import {
setDesktopRemoteMediaSession,
subscribeDesktopRemoteMediaSessionCommands,
} from '@/services/desktopRemoteMediaSession';
import {
getDesktopRemoteConnection,
setDesktopRemoteConnection,
} from '@/services/desktopRemoteCredentials';
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
import { colors } from '@/theme';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
@@ -107,6 +115,113 @@ function DesktopRemoteMediaSessionSync() {
return null;
}
const DESKTOP_DISCOVERY_BURST_MS = 20_000;
const DESKTOP_SYNC_STARTUP_RETRY_MS = 2_500;
const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000;
/**
* Auto-syncs favorites/playlists with the paired desktop when it looks
* reachable: on foreground (probe-guarded, min-interval limited), when mDNS
* discovery spots the paired desktop, or when the remote screen connects.
* Deliberately does NOT init the desktop-remote store here — its connect path
* retries a powered-off desktop every 2 s forever, which we don't want running
* from app launch.
*/
function DesktopSyncAutoTrigger() {
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const discovered = useDesktopRemoteStore((s) => s.discovered);
useEffect(() => {
void useDesktopSyncStore.getState().hydrate();
}, []);
// Cold start + each return to foreground: attempt a (probe-guarded) sync and
// run a short mDNS burst so a desktop that changed LAN address is found.
useEffect(() => {
let burstTimer: ReturnType<typeof setTimeout> | null = null;
let startupRetryTimer: ReturnType<typeof setTimeout> | null = null;
const onActive = () => {
void (async () => {
const connection = await getDesktopRemoteConnection();
if (!connection) return;
useDesktopSyncStore.getState().maybeAutoSync('foreground');
const remote = useDesktopRemoteStore.getState();
if (remote.discoveryAvailable && !remote.discoveryRunning) {
void remote.startDiscovery();
burstTimer = setTimeout(() => {
burstTimer = null;
void useDesktopRemoteStore.getState().stopDiscovery();
}, DESKTOP_DISCOVERY_BURST_MS);
}
})();
};
if (AppState.currentState === 'active') {
onActive();
} else {
startupRetryTimer = setTimeout(() => {
startupRetryTimer = null;
if (AppState.currentState === 'active') onActive();
}, DESKTOP_SYNC_STARTUP_RETRY_MS);
}
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') onActive();
});
return () => {
subscription.remove();
if (burstTimer !== null) clearTimeout(burstTimer);
if (startupRetryTimer !== null) clearTimeout(startupRetryTimer);
};
}, []);
// Desktop-initiated "Sync now" pickup: a cheap identity poll while
// foregrounded (the SSE nudge only reaches us while the remote screen's
// stream happens to be connected). fetchDesktopRemoteIdentity swallows
// errors, so a powered-off desktop costs one timed-out request per minute.
useEffect(() => {
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
void (async () => {
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
if (identity?.syncRequestedAt) {
useDesktopSyncStore.getState().handleSyncRequest();
}
})();
}, DESKTOP_SYNC_REQUEST_POLL_MS);
return () => clearInterval(timer);
}, []);
// Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves)
// and trigger a sync.
useEffect(() => {
if (discovered.length === 0) return;
void (async () => {
const connection = await getDesktopRemoteConnection();
if (!connection?.endpointUuid) return;
const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid);
if (!match) return;
if (match.baseUrl && match.baseUrl !== connection.baseUrl) {
const updated = { ...connection, baseUrl: match.baseUrl };
await setDesktopRemoteConnection(updated);
if (useDesktopRemoteStore.getState().connection) {
useDesktopRemoteStore.setState({ connection: updated });
}
}
useDesktopSyncStore.getState().maybeAutoSync('discovery');
})();
}, [discovered]);
// The remote screen connected — the desktop is definitely reachable.
useEffect(() => {
if (connectionState === 'connected') {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
}, [connectionState]);
return null;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
@@ -176,6 +291,7 @@ export default function RootLayout() {
<NormalizationSync />
<LastFmScrobbler />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
<Stack
screenOptions={{
headerShown: false,
@@ -194,6 +310,7 @@ export default function RootLayout() {
/>
</Stack>
<QuickSearchOverlay />
<SyncConflictPrompt />
</SafeAreaProvider>
</GestureHandlerRootView>
);
+170 -49
View File
@@ -16,7 +16,7 @@ import {
View
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
@@ -24,21 +24,33 @@ import { MarqueeText } from '@/components/MarqueeText';
import { Screen } from '@/components/Screen';
import { SeekBar } from '@/components/SeekBar';
import { Text } from '@/components/Text';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import {
colors,
radius,
spacing
} from '@/theme';
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
// Layout mirrors now-playing's adaptive pattern (minus the scope stage): a
// wide two-pane branch, and a portrait branch where the controls own ALL real
// leftover space (flex + space-between) so height-estimate error spreads
// between the control rows instead of pooling as one dead gap.
const MAX_CONTENT_WIDTH = 408;
const TABLET_MAX_CONTENT_WIDTH = 520;
const TABLET_ART_SIZE_MAX = 440;
const CONTENT_SIDE_PADDING = spacing.lg;
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
const WIDE_MAX_CONTENT_WIDTH = 960;
const WIDE_PANE_GAP = spacing.xxl;
const WIDE_RIGHT_PANE_MIN = 300;
const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH;
const WIDE_ART_SIZE_MAX = 400;
const WIDE_ART_SIZE_MIN = 160;
const WIDE_COMPACT_HEIGHT = 480;
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;
@@ -56,9 +68,14 @@ const SUB_TOP_MARGIN = spacing.lg;
const MIN_FLOATING_SPACE = spacing.sm;
interface RemoteLayout {
isWide: boolean;
contentPadding: number;
contentWidth: number;
leftPaneWidth: number;
rightPaneWidth: number;
controlsGap: number;
artSize: number;
mediaStackHeight: number;
mediaTopMargin: number;
mediaBottomGap: number;
}
@@ -67,21 +84,51 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLayout {
function getRemoteLayout(availableWidth: number, availableHeight: number): RemoteLayout {
if (isWideWindow(availableWidth, availableHeight)) {
const contentPadding = CONTENT_SIDE_PADDING;
const contentWidth = Math.max(
0,
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
);
const rightPaneWidth = Math.round(
clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX)
);
const leftPaneWidth = Math.max(0, contentWidth - WIDE_PANE_GAP - rightPaneWidth);
const verticalBudget =
availableHeight - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md;
const artSize = Math.round(
clamp(Math.min(leftPaneWidth, verticalBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX)
);
return {
isWide: true,
contentPadding,
contentWidth,
leftPaneWidth,
rightPaneWidth,
controlsGap: availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg,
artSize,
mediaStackHeight: artSize,
mediaTopMargin: 0,
mediaBottomGap: 0,
};
}
// Tall windows: single column. Tablet-width ones get a larger column/art cap.
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
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);
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth));
const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth);
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
const fixedHeight =
const fixedHeightBase =
CONTENT_TOP_PADDING +
CONTENT_BOTTOM_PADDING +
HEADER_HEIGHT +
mediaTopMargin +
mediaBottomGap +
TRACK_INFO_ESTIMATE +
SEEK_BLOCK_ESTIMATE +
TRANSPORT_TOP_MARGIN +
@@ -89,16 +136,19 @@ function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLa
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
);
// The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat
// windows (split-screen halves) instead of pushing the controls off-screen.
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
const artSize = Math.round(clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax));
return {
isWide: false,
contentPadding,
contentWidth,
leftPaneWidth: contentWidth,
rightPaneWidth: contentWidth,
controlsGap: TRANSPORT_TOP_MARGIN,
artSize,
mediaStackHeight: artSize,
mediaTopMargin,
mediaBottomGap,
};
@@ -197,12 +247,14 @@ export default function DesktopRemoteScreen() {
const reconnect = useDesktopRemoteStore((s) => s.reconnect);
const forget = useDesktopRemoteStore((s) => s.forget);
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
const queue = useDesktopRemoteStore((s) => s.queue);
const [pairingLink, setPairingLink] = useState('');
const [pinInput, setPinInput] = useState('');
const [pinClock, setPinClock] = useState(() => Date.now());
const [manualBaseUrl, setManualBaseUrl] = useState('');
const [manualTicket, setManualTicket] = useState('');
const [queueOpen, setQueueOpen] = useState(false);
useEffect(() => {
void init();
@@ -234,9 +286,16 @@ export default function DesktopRemoteScreen() {
const art = currentTrack?.artworkDataUrl ?? null;
const accent = snapshot?.visualizerLineColor || colors.accent;
const availableHeight = windowHeight - insets.top - insets.bottom;
const remoteLayout = getRemoteLayout(windowWidth, availableHeight);
const effectiveWidth = windowWidth - insets.left - insets.right;
const remoteLayout = getRemoteLayout(effectiveWidth, availableHeight);
const remoteSource = connection?.desktopName ?? 'Astra Desktop';
const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : '');
// Live protocol gate: protocol-1 desktops omit shuffle/repeat from the
// snapshot and 404 the queue endpoint (queue stays null).
const supportsShuffleRepeat = snapshot?.shuffle !== undefined;
const shuffleOn = snapshot?.shuffle === true;
const repeatMode = snapshot?.repeat ?? 'none';
const queueAvailable = queue !== null;
const countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : '';
const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
@@ -499,14 +558,17 @@ export default function DesktopRemoteScreen() {
</View>
{currentTrack ? (
<View style={styles.remotePlayer}>
<View style={[styles.remotePlayer, remoteLayout.isWide && styles.remotePlayerWide]}>
<View
style={[
styles.middleStack,
{
marginTop: remoteLayout.mediaTopMargin,
marginBottom: remoteLayout.mediaBottomGap,
},
remoteLayout.isWide
? { width: remoteLayout.leftPaneWidth, justifyContent: 'center' }
: {
height: remoteLayout.mediaStackHeight,
marginTop: remoteLayout.mediaTopMargin,
marginBottom: remoteLayout.mediaBottomGap,
},
]}
>
<View
@@ -531,9 +593,14 @@ export default function DesktopRemoteScreen() {
</View>
</View>
<View style={styles.spacer} />
<View style={styles.playerControls}>
<View
style={[
styles.playerControls,
remoteLayout.isWide
? { width: remoteLayout.rightPaneWidth }
: styles.playerControlsFill,
]}
>
<View style={styles.trackInfo}>
<View style={styles.trackTextStack}>
<MarqueeText
@@ -547,6 +614,19 @@ export default function DesktopRemoteScreen() {
{currentTrack.artist || currentTrack.album || remoteSource}
</MarqueeText>
</View>
<Pressable
hitSlop={10}
style={styles.inlineActionBtn}
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>
<SeekBar
@@ -556,14 +636,20 @@ export default function DesktopRemoteScreen() {
onSeek={(seconds) => void sendControl('seek', seconds)}
/>
<View style={styles.transport}>
<View style={[styles.transport, { marginTop: remoteLayout.controlsGap }]}>
<Pressable
hitSlop={10}
style={styles.transportSideBtn}
onPress={() => void reconnect()}
accessibilityLabel="Reconnect"
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
disabled={!supportsShuffleRepeat}
onPress={() => void sendControl('toggle-shuffle')}
accessibilityLabel="Shuffle"
accessibilityState={{ selected: shuffleOn }}
>
<Ionicons name="refresh" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
<Ionicons
name="shuffle"
size={SUB_ICON_SIZE + 2}
color={shuffleOn ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
onPress={() => void sendControl('previous')}
@@ -595,20 +681,29 @@ export default function DesktopRemoteScreen() {
</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 }}
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
disabled={!supportsShuffleRepeat}
onPress={() => void sendControl('toggle-repeat')}
accessibilityLabel="Repeat"
accessibilityState={{ selected: repeatMode !== 'none' }}
>
<Ionicons
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
size={SUB_ICON_SIZE + 4}
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
/>
{repeatMode === 'one' ? (
<MaterialCommunityIcons
name="repeat-once"
size={SUB_ICON_SIZE + 2}
color={colors.accent}
/>
) : (
<Ionicons
name="repeat"
size={SUB_ICON_SIZE + 2}
color={repeatMode === 'all' ? colors.accent : colors.textTertiary}
/>
)}
</Pressable>
</View>
<View style={styles.subRow}>
<View style={[styles.subRow, { marginTop: remoteLayout.controlsGap }]}>
<View style={styles.statusPill}>
<View
style={[
@@ -623,6 +718,16 @@ export default function DesktopRemoteScreen() {
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
{remoteDetail}
</Text>
{queueAvailable ? (
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Desktop queue"
>
<Ionicons name="list-outline" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
</Pressable>
) : null}
<Pressable
hitSlop={10}
style={styles.subBtn}
@@ -683,6 +788,7 @@ export default function DesktopRemoteScreen() {
</>
)}
</KeyboardAvoidingView>
{queueOpen && <RemoteQueueSheet onClose={() => setQueueOpen(false)} />}
</Screen>
);
}
@@ -882,6 +988,12 @@ const styles = StyleSheet.create({
remotePlayer: {
flex: 1,
},
remotePlayerWide: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
columnGap: WIDE_PANE_GAP,
},
middleStack: {
width: '100%',
alignItems: 'center',
@@ -897,13 +1009,15 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
spacer: {
flex: 1,
minHeight: MIN_FLOATING_SPACE,
},
playerControls: {
width: '100%',
},
// Portrait: the controls own all real leftover space; spare pixels spread
// evenly between the rows instead of pooling above the track title.
playerControlsFill: {
flex: 1,
justifyContent: 'space-between',
},
trackInfo: {
alignSelf: 'stretch',
flexDirection: 'row',
@@ -929,7 +1043,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: TRANSPORT_TOP_MARGIN,
},
transportMainBtn: {
width: 48,
@@ -943,6 +1056,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
transportSideBtnDisabled: {
opacity: 0.35,
},
inlineActionBtn: {
width: SUB_BUTTON_SIZE,
height: SUB_BUTTON_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
playButton: {
width: PLAY_BUTTON_SIZE,
height: PLAY_BUTTON_SIZE,
@@ -955,8 +1077,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
marginTop: SUB_TOP_MARGIN,
gap: spacing.sm,
paddingHorizontal: spacing.sm,
},
subBtn: {
+408
View File
@@ -0,0 +1,408 @@
// Desktop Sync — favorites/playlists library sync with the paired desktop.
// Separate surface from the Desktop Remote controller: it shares the pairing
// (one paired desktop serves both features) but nothing else. Conflict
// resolution (Steam-Cloud style) lives inline here; the desktop mirrors the
// same conflicts in its own settings and either side may resolve them.
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
import { colors, radius, spacing } from '@/theme';
import { formatRelativeTime } from '@/lib/format';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import {
buildSyncConflictResolutionPreview,
syncPlaylistToSnapshot,
} from '@/shared/sync/conflictPreview';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
} from '@/types/desktopSync';
const RESOLUTION_LABELS: Record<DesktopSyncConflictResolution, string> = {
desktop: 'Use desktop',
phone: 'Use phone',
both: 'Keep both',
merge: 'Combine songs',
};
function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] {
return conflict.playlistKind === 'dynamic'
? ['desktop', 'phone', 'both']
: ['desktop', 'phone', 'both', 'merge'];
}
function conflictDescription(conflict: DesktopSyncPlaylistConflict): string {
if (conflict.kind === 'first-pairing') {
return conflict.playlistKind === 'dynamic'
? 'Exists on both devices with different rules.'
: 'Exists on both devices with different songs.';
}
return 'Edited on both devices since the last sync.';
}
function ConflictCard({
conflict,
desktopName,
busy,
onResolve,
}: {
conflict: DesktopSyncPlaylistConflict;
desktopName: string;
busy: boolean;
onResolve: (resolution: DesktopSyncConflictResolution) => void;
}) {
const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null);
const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
const phoneSnapshot = syncPlaylistToSnapshot(conflict.local);
const options = resolutionOptions(conflict);
const preview = selectedResolution
? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot)
: null;
return (
<View style={styles.conflictCard}>
<Text variant="body" numberOfLines={1}>
{conflict.localName}
</Text>
<Text variant="caption" color={colors.textSecondary}>
{conflictDescription(conflict)}
</Text>
<View style={styles.conflictActions}>
{options.map((resolution) => (
<Pressable
key={resolution}
style={[
styles.conflictBtn,
selectedResolution === resolution ? styles.conflictBtnSelected : null,
busy && styles.disabled,
]}
disabled={busy}
onPress={() => setSelectedResolution(resolution)}
>
<Text variant="label">{RESOLUTION_LABELS[resolution]}</Text>
</Pressable>
))}
</View>
<View style={styles.previewBox}>
<Text variant="label">
{preview ? preview.title : 'Choose an option to preview it'}
</Text>
<Text variant="caption" color={colors.textSecondary}>
{preview ? preview.detail : 'Nothing changes until you confirm.'}
</Text>
</View>
<SyncConflictDetails
conflict={conflict}
desktopName={desktopName}
maxRows={5}
previewResolution={selectedResolution}
/>
<View style={styles.conflictConfirmRow}>
<Pressable
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
disabled={!selectedResolution || busy}
onPress={() => selectedResolution ? onResolve(selectedResolution) : undefined}
>
<Text variant="body" color={colors.accentTextStrong}>
Confirm
</Text>
</Pressable>
</View>
</View>
);
}
export default function DesktopSyncScreen() {
const router = useRouter();
const status = useDesktopSyncStore((s) => s.status);
const lastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const lastSummary = useDesktopSyncStore((s) => s.lastSummary);
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const errorMessage = useDesktopSyncStore((s) => s.errorMessage);
const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled);
const syncNow = useDesktopSyncStore((s) => s.syncNow);
const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled);
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
const [connection, setConnection] = useState<DesktopRemoteConnection | null>(null);
const [connectionLoaded, setConnectionLoaded] = useState(false);
useEffect(() => {
void useDesktopSyncStore.getState().hydrate();
void getDesktopRemoteConnection().then((stored) => {
setConnection(stored);
setConnectionLoaded(true);
});
}, []);
const desktopName = connection?.desktopName ?? 'Astra Desktop';
const syncing = status === 'syncing';
const summaryLine = lastSummary
? [
lastSummary.favoritesAdded > 0 ? `${lastSummary.favoritesAdded} favorites added` : null,
lastSummary.favoritesRemoved > 0 ? `${lastSummary.favoritesRemoved} removed` : null,
lastSummary.playlistsCreated + lastSummary.playlistsReplaced > 0
? `${lastSummary.playlistsCreated + lastSummary.playlistsReplaced} playlists updated`
: null,
lastSummary.playlistsDeleted > 0 ? `${lastSummary.playlistsDeleted} playlists removed` : null,
lastSummary.favoritesPending > 0 ? `${lastSummary.favoritesPending} pending a library match` : null,
]
.filter((part): part is string => part !== null)
.join(' · ')
: '';
return (
<Screen>
<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>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<View style={styles.hero}>
<Ionicons name="sync-outline" size={30} color={colors.accent} />
<View style={styles.heroText}>
<Text variant="title" style={styles.heading}>
Desktop Sync
</Text>
<Text variant="body" color={colors.textSecondary}>
Keep favorites and playlists in step with Astra Desktop over your LAN.
</Text>
</View>
</View>
{!connectionLoaded ? (
<ActivityIndicator color={colors.accent} />
) : !connection ? (
<View style={styles.card}>
<Text variant="body">No desktop paired</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop
once and both features work.
</Text>
<Pressable
style={styles.primaryButton}
onPress={() => router.push('/desktop-remote' as never)}
>
<Ionicons name="link-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Pair with a desktop
</Text>
</Pressable>
</View>
) : (
<>
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderText}>
<Text variant="body">{desktopName}</Text>
<Text variant="caption" color={colors.textSecondary}>
{syncing
? 'Syncing…'
: lastSyncAt !== null
? `Synced ${formatRelativeTime(lastSyncAt)}`
: 'Not synced yet'}
</Text>
</View>
<Pressable
style={[styles.primaryButton, syncing && styles.disabled]}
disabled={syncing}
onPress={() => void syncNow()}
accessibilityLabel="Sync favorites and playlists now"
>
{syncing ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Ionicons name="sync-outline" size={18} color={colors.accentTextStrong} />
)}
<Text variant="body" color={colors.accentTextStrong}>
Sync now
</Text>
</Pressable>
</View>
{summaryLine ? (
<Text variant="caption" color={colors.textTertiary}>
Last sync: {summaryLine}
</Text>
) : null}
{errorMessage ? (
<Text variant="caption" color={colors.warning}>
{errorMessage}
</Text>
) : null}
</View>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">Sync automatically</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Sync when this desktop appears on the network or the app returns to the
foreground. Manual and desktop-requested syncs always work.
</Text>
</View>
<Switch
value={autoSyncEnabled}
onValueChange={(value) => void setAutoSyncEnabled(value)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
{conflicts.length > 0 ? (
<View style={styles.card}>
<Text variant="body">
{conflicts.length === 1 ? '1 conflict' : `${conflicts.length} conflicts`} to
resolve
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
These playlists differ between devices. Nothing changes until you choose
everything else already synced. You can also resolve these from the desktop
settings.
</Text>
{conflicts.map((conflict) => (
<ConflictCard
key={conflict.syncUid}
conflict={conflict}
desktopName={desktopName}
busy={syncing}
onResolve={(resolution) => void resolveConflict(conflict, resolution)}
/>
))}
</View>
) : null}
</>
)}
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
topBar: {
marginTop: spacing.md,
marginBottom: spacing.sm,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
content: {
paddingBottom: spacing.xxl,
gap: spacing.md,
},
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',
gap: spacing.md,
},
cardHeaderText: {
flex: 1,
minWidth: 0,
},
cardCopy: {
lineHeight: 19,
},
primaryButton: {
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: spacing.sm,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toggleText: {
flex: 1,
},
conflictCard: {
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
padding: spacing.md,
gap: spacing.xs,
},
conflictActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginTop: spacing.sm,
},
conflictBtn: {
minHeight: 36,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
conflictBtnSelected: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
previewBox: {
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
padding: spacing.md,
gap: spacing.xs,
marginTop: spacing.xs,
},
conflictConfirmRow: {
alignItems: 'flex-end',
marginTop: spacing.xs,
},
disabled: {
opacity: 0.5,
},
});