From 2b19f51a49fbb69bba1515a042b978976303c482 Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Mon, 6 Jul 2026 14:14:41 -0400
Subject: [PATCH] desktop sync support
---
package.json | 1 +
src/app/(tabs)/settings.tsx | 32 +
src/app/_layout.tsx | 117 +++
src/app/desktop-remote.tsx | 219 ++++--
src/app/desktop-sync.tsx | 408 +++++++++++
src/components/queue/RemoteQueueSheet.tsx | 173 +++++
src/components/sync/SyncConflictDetails.tsx | 347 +++++++++
src/components/sync/SyncConflictPrompt.tsx | 314 ++++++++
src/db/desktopSyncQueries.ts | 454 ++++++++++++
src/db/playlistQueries.ts | 55 +-
src/db/schema.ts | 46 +-
src/lib/format.ts | 12 +
src/library/importMatching.test.mts | 159 ++++
src/library/importMatching.ts | 238 ++++++
src/library/playlistFiles.ts | 164 +----
src/services/desktopRemoteClient.ts | 90 ++-
src/services/desktopSync.ts | 678 ++++++++++++++++++
.../desktopSyncPlaylistMerge.test.mts | 67 ++
src/services/desktopSyncPlaylistMerge.ts | 50 ++
src/shared/sync/conflictPreview.test.mts | 93 +++
src/shared/sync/conflictPreview.ts | 194 +++++
src/shared/sync/identity.ts | 21 +
src/stores/desktopRemoteStore.ts | 61 +-
src/stores/desktopSyncStore.ts | 199 +++++
src/types/desktopRemote.ts | 24 +-
src/types/desktopSync.ts | 175 +++++
26 files changed, 4183 insertions(+), 208 deletions(-)
create mode 100644 src/app/desktop-sync.tsx
create mode 100644 src/components/queue/RemoteQueueSheet.tsx
create mode 100644 src/components/sync/SyncConflictDetails.tsx
create mode 100644 src/components/sync/SyncConflictPrompt.tsx
create mode 100644 src/db/desktopSyncQueries.ts
create mode 100644 src/library/importMatching.test.mts
create mode 100644 src/library/importMatching.ts
create mode 100644 src/services/desktopSync.ts
create mode 100644 src/services/desktopSyncPlaylistMerge.test.mts
create mode 100644 src/services/desktopSyncPlaylistMerge.ts
create mode 100644 src/shared/sync/conflictPreview.test.mts
create mode 100644 src/shared/sync/conflictPreview.ts
create mode 100644 src/shared/sync/identity.ts
create mode 100644 src/stores/desktopSyncStore.ts
create mode 100644 src/types/desktopSync.ts
diff --git a/package.json b/package.json
index 26cd53e..97d2f2e 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
+ "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
index 13542c0..2339448 100644
--- a/src/app/(tabs)/settings.tsx
+++ b/src/app/(tabs)/settings.tsx
@@ -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() {
+ router.push('/desktop-sync' as never)}
+ accessibilityRole="button"
+ >
+
+
+ Desktop Sync
+ 0 ? colors.warning : colors.textSecondary}
+ style={styles.optionDescription}
+ >
+ {desktopSyncSubtitle}
+
+
+
+
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 | null = null;
+ let startupRetryTimer: ReturnType | 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() {
+
+
);
diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx
index a7719a0..1a9cbce 100644
--- a/src/app/desktop-remote.tsx
+++ b/src/app/desktop-remote.tsx
@@ -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() {
{currentTrack ? (
-
+
-
-
-
+
+ void sendControl('toggle-favorite')}
+ accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+ accessibilityState={{ selected: currentTrack.isFavorite }}
+ >
+
+
void sendControl('seek', seconds)}
/>
-
+
void reconnect()}
- accessibilityLabel="Reconnect"
+ style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
+ disabled={!supportsShuffleRepeat}
+ onPress={() => void sendControl('toggle-shuffle')}
+ accessibilityLabel="Shuffle"
+ accessibilityState={{ selected: shuffleOn }}
>
-
+
void sendControl('previous')}
@@ -595,20 +681,29 @@ export default function DesktopRemoteScreen() {
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' }}
>
-
+ {repeatMode === 'one' ? (
+
+ ) : (
+
+ )}
-
+
{remoteDetail}
+ {queueAvailable ? (
+ setQueueOpen(true)}
+ accessibilityLabel="Desktop queue"
+ >
+
+
+ ) : null}
)}
+ {queueOpen && setQueueOpen(false)} />}
);
}
@@ -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: {
diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx
new file mode 100644
index 0000000..d3fa1bf
--- /dev/null
+++ b/src/app/desktop-sync.tsx
@@ -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 = {
+ 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(null);
+ const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
+ const phoneSnapshot = syncPlaylistToSnapshot(conflict.local);
+ const options = resolutionOptions(conflict);
+ const preview = selectedResolution
+ ? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot)
+ : null;
+ return (
+
+
+ {conflict.localName}
+
+
+ {conflictDescription(conflict)}
+
+
+ {options.map((resolution) => (
+ setSelectedResolution(resolution)}
+ >
+ {RESOLUTION_LABELS[resolution]}
+
+ ))}
+
+
+
+ {preview ? preview.title : 'Choose an option to preview it'}
+
+
+ {preview ? preview.detail : 'Nothing changes until you confirm.'}
+
+
+
+
+ selectedResolution ? onResolve(selectedResolution) : undefined}
+ >
+
+ Confirm
+
+
+
+
+ );
+}
+
+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(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 (
+
+
+ router.back()} hitSlop={8}>
+
+
+ Settings
+
+
+
+
+
+
+
+
+ Desktop Sync
+
+
+ Keep favorites and playlists in step with Astra Desktop over your LAN.
+
+
+
+
+ {!connectionLoaded ? (
+
+ ) : !connection ? (
+
+ No desktop paired
+
+ Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop
+ once and both features work.
+
+ router.push('/desktop-remote' as never)}
+ >
+
+
+ Pair with a desktop
+
+
+
+ ) : (
+ <>
+
+
+
+ {desktopName}
+
+ {syncing
+ ? 'Syncing…'
+ : lastSyncAt !== null
+ ? `Synced ${formatRelativeTime(lastSyncAt)}`
+ : 'Not synced yet'}
+
+
+ void syncNow()}
+ accessibilityLabel="Sync favorites and playlists now"
+ >
+ {syncing ? (
+
+ ) : (
+
+ )}
+
+ Sync now
+
+
+
+ {summaryLine ? (
+
+ Last sync: {summaryLine}
+
+ ) : null}
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+
+ Sync automatically
+
+ Sync when this desktop appears on the network or the app returns to the
+ foreground. Manual and desktop-requested syncs always work.
+
+
+ void setAutoSyncEnabled(value)}
+ trackColor={{ false: colors.glassBorder, true: colors.accent }}
+ thumbColor={colors.textPrimary}
+ />
+
+
+
+ {conflicts.length > 0 ? (
+
+
+ {conflicts.length === 1 ? '1 conflict' : `${conflicts.length} conflicts`} to
+ resolve
+
+
+ These playlists differ between devices. Nothing changes until you choose —
+ everything else already synced. You can also resolve these from the desktop
+ settings.
+
+ {conflicts.map((conflict) => (
+ void resolveConflict(conflict, resolution)}
+ />
+ ))}
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/components/queue/RemoteQueueSheet.tsx b/src/components/queue/RemoteQueueSheet.tsx
new file mode 100644
index 0000000..9db580d
--- /dev/null
+++ b/src/components/queue/RemoteQueueSheet.tsx
@@ -0,0 +1,173 @@
+// Read-only queue sheet for the Desktop Remote: the desktop's current +
+// upcoming tracks, tap-to-play. Deliberately NOT QueueTray — that component is
+// welded to the local RNTP queue store (drag-reorder, swipe-remove,
+// multi-select), none of which applies to a remote snapshot. Uses an INLINE
+// BottomSheet like QueueTray does — BottomSheetModal's portal does not work in
+// this app's screen setups (see queue-tray-sheet gotcha).
+
+import { useCallback, useEffect, useMemo } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import BottomSheet, {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ useBottomSheetScrollableCreator,
+} from '@gorhom/bottom-sheet';
+import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
+import { Text } from '@/components/Text';
+import { colors, radius, spacing } from '@/theme';
+import { formatDuration } from '@/lib/format';
+import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
+import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
+
+interface RemoteQueueSheetProps {
+ onClose: () => void;
+}
+
+export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
+ const queue = useDesktopRemoteStore((s) => s.queue);
+ const snapPoints = useMemo(() => ['58%', '100%'], []);
+ const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
+
+ // The SSE stream keeps the queue fresh while connected; refresh once on open
+ // in case the stream fell back to snapshot polling (which has no queue).
+ useEffect(() => {
+ void useDesktopRemoteStore.getState().refreshQueue();
+ }, []);
+
+ const items = queue?.items ?? [];
+ const upcomingCount = items.filter((item) => !item.isCurrent).length;
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ []
+ );
+
+ const playItem = useCallback(
+ (item: DesktopRemoteQueueItem) => {
+ if (item.isCurrent) return;
+ void useDesktopRemoteStore.getState().playQueueItem(item.queueId);
+ onClose();
+ },
+ [onClose]
+ );
+
+ const renderItem = useCallback(
+ ({ item }: ListRenderItemInfo) => (
+ [styles.row, pressed && !item.isCurrent && styles.rowPressed]}
+ onPress={() => playItem(item)}
+ disabled={item.isCurrent}
+ accessibilityRole="button"
+ accessibilityLabel={
+ item.isCurrent ? `Now playing: ${item.title}` : `Play ${item.title} on desktop`
+ }
+ >
+
+
+ {item.title || 'Unknown title'}
+
+
+ {item.artist || 'Unknown artist'}
+
+
+ {item.isCurrent ? (
+
+ ) : item.durationSeconds !== null ? (
+
+ {formatDuration(item.durationSeconds)}
+
+ ) : null}
+
+ ),
+ [playItem]
+ );
+
+ return (
+
+
+ Desktop queue
+
+ {upcomingCount === 1 ? '1 song up next' : `${upcomingCount} songs up next`}
+
+
+ item.queueId}
+ renderScrollComponent={renderFlashListScrollComponent}
+ renderItem={renderItem}
+ contentContainerStyle={styles.listContent}
+ showsVerticalScrollIndicator={false}
+ ListEmptyComponent={
+
+
+ The desktop queue is empty.
+
+
+ }
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ sheetBg: {
+ backgroundColor: colors.bgSecondary,
+ borderRadius: radius.lg,
+ },
+ handle: {
+ backgroundColor: colors.textTertiary,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.sm,
+ },
+ listContent: {
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.xl,
+ },
+ row: {
+ minHeight: 56,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ },
+ rowPressed: {
+ opacity: 0.6,
+ },
+ rowText: {
+ flex: 1,
+ minWidth: 0,
+ },
+ titleActive: {
+ color: colors.accent,
+ },
+ empty: {
+ paddingVertical: spacing.xl,
+ alignItems: 'center',
+ },
+});
diff --git a/src/components/sync/SyncConflictDetails.tsx b/src/components/sync/SyncConflictDetails.tsx
new file mode 100644
index 0000000..77afbe9
--- /dev/null
+++ b/src/components/sync/SyncConflictDetails.tsx
@@ -0,0 +1,347 @@
+import { StyleSheet, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Text } from '@/components/Text';
+import { colors, radius, spacing } from '@/theme';
+import { formatRelativeTime } from '@/lib/format';
+import {
+ buildSyncPlaylistEntryDiff,
+ syncPlaylistToSnapshot,
+ type SyncPlaylistEntryDiff,
+} from '@/shared/sync/conflictPreview';
+import type {
+ DesktopSyncConflictResolution,
+ DesktopSyncPlaylistConflict,
+ SyncPlaylistSnapshot,
+} from '@/types/desktopSync';
+
+function playlistKindLabel(snapshot: SyncPlaylistSnapshot): string {
+ return snapshot.kind === 'dynamic'
+ ? 'Dynamic playlist'
+ : `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`;
+}
+
+function sideSummary(snapshot: SyncPlaylistSnapshot): string {
+ return `${playlistKindLabel(snapshot)} · edited ${formatRelativeTime(snapshot.updatedAt)}`;
+}
+
+function diffSummary(conflict: DesktopSyncPlaylistConflict): string {
+ const desktop = syncPlaylistToSnapshot(conflict.remote);
+ const phone = syncPlaylistToSnapshot(conflict.local);
+ if (desktop.kind !== 'normal' || phone.kind !== 'normal') {
+ return desktop.dynamicRules === phone.dynamicRules
+ ? 'Names or dynamic playlist metadata differ.'
+ : 'Dynamic playlist rules differ.';
+ }
+
+ const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries);
+ const parts = [
+ diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null,
+ diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null,
+ diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null,
+ ].filter((part): part is string => part !== null);
+
+ if (parts.length > 0) return parts.join(' · ');
+ if (desktop.name.trim() !== phone.name.trim()) return 'Playlist names differ.';
+ return 'Same songs; playlist metadata differs.';
+}
+
+function previewStatusLabel(
+ row: SyncPlaylistEntryDiff,
+ side: 'desktop' | 'phone',
+ resolution: DesktopSyncConflictResolution | null
+): string | null {
+ if (!resolution) return null;
+ if (resolution === 'both') return 'Stays separate';
+ if (resolution === 'merge') return row.status === 'moved' ? 'Order chosen' : 'Added';
+
+ const keptSide = resolution;
+ if (side === keptSide) return row.status === 'moved' ? 'Order kept' : 'Kept';
+ return row.status === 'moved' ? 'Order changes' : 'Removed';
+}
+
+function moveStatusLabel(row: SyncPlaylistEntryDiff, side: 'desktop' | 'phone'): string {
+ if (row.status === 'moved') {
+ const from = side === 'desktop' ? row.desktopIndex : row.phoneIndex;
+ const to = side === 'desktop' ? row.phoneIndex : row.desktopIndex;
+ return from !== null && to !== null ? `${from + 1} to ${to + 1}` : 'Different order';
+ }
+ return '';
+}
+
+function TrackDiffRow({
+ row,
+ side,
+ previewResolution,
+}: {
+ row: SyncPlaylistEntryDiff;
+ side: 'desktop' | 'phone';
+ previewResolution: DesktopSyncConflictResolution | null;
+}) {
+ const subtitle = [row.artist, row.album].filter((part) => part.trim().length > 0).join(' · ');
+ const previewLabel = previewStatusLabel(row, side, previewResolution);
+ const moveLabel = row.status === 'moved' && !previewLabel ? moveStatusLabel(row, side) : null;
+ return (
+
+
+
+ {row.title || 'Untitled track'}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {previewLabel || moveLabel ? (
+
+ {previewLabel ?? moveLabel}
+
+ ) : null}
+
+ );
+}
+
+function SideTrackList({
+ side,
+ sideOnlyRows,
+ movedRows,
+ previewResolution,
+ maxRows,
+}: {
+ side: 'desktop' | 'phone';
+ sideOnlyRows: SyncPlaylistEntryDiff[];
+ movedRows: SyncPlaylistEntryDiff[];
+ previewResolution: DesktopSyncConflictResolution | null;
+ maxRows: number;
+}) {
+ const rows = [...sideOnlyRows, ...movedRows].slice(0, maxRows);
+ const hiddenCount = Math.max(0, sideOnlyRows.length + movedRows.length - rows.length);
+ const sideName = side === 'desktop' ? 'desktop' : 'phone';
+
+ if (rows.length === 0) {
+ return (
+
+ No songs only on {sideName}.
+
+ );
+ }
+
+ return (
+
+ {sideOnlyRows.length > 0 ? (
+
+ Only on {sideName}
+
+ ) : null}
+ {rows.map((row, index) => {
+ const startsMovedSection = row.status === 'moved' && rows[index - 1]?.status !== 'moved';
+ return (
+
+ {startsMovedSection ? (
+
+ Different order
+
+ ) : null}
+
+
+ );
+ })}
+ {hiddenCount > 0 ? (
+
+ +{hiddenCount} more
+
+ ) : null}
+
+ );
+}
+
+export function SyncConflictDetails({
+ conflict,
+ desktopName,
+ maxRows = 4,
+ previewResolution = null,
+}: {
+ conflict: DesktopSyncPlaylistConflict;
+ desktopName: string;
+ maxRows?: number;
+ previewResolution?: DesktopSyncConflictResolution | null;
+}) {
+ const desktop = syncPlaylistToSnapshot(conflict.remote);
+ const phone = syncPlaylistToSnapshot(conflict.local);
+ const isNormal = desktop.kind === 'normal' && phone.kind === 'normal';
+ const diff = isNormal ? buildSyncPlaylistEntryDiff(desktop.entries, phone.entries) : null;
+ const desktopOnlyRows = diff?.rows.filter((row) => row.status === 'desktop-only') ?? [];
+ const phoneOnlyRows = diff?.rows.filter((row) => row.status === 'phone-only') ?? [];
+ const movedRows = diff?.rows.filter((row) => row.status === 'moved') ?? [];
+ const desktopDimmed = previewResolution === 'phone';
+ const phoneDimmed = previewResolution === 'desktop';
+ const desktopActive = previewResolution === 'desktop' || previewResolution === 'both' || previewResolution === 'merge';
+ const phoneActive = previewResolution === 'phone' || previewResolution === 'both' || previewResolution === 'merge';
+
+ return (
+
+
+
+
+
+
+ {desktopName}
+
+
+
+ {desktop.name}
+
+
+ {sideSummary(desktop)}
+
+ {isNormal ? (
+
+ ) : (
+
+ {desktop.dynamicRules ?? 'No rules'}
+
+ )}
+
+
+
+
+
+
+ This phone
+
+
+
+ {phone.name}
+
+
+ {sideSummary(phone)}
+
+ {isNormal ? (
+
+ ) : (
+
+ {phone.dynamicRules ?? 'No rules'}
+
+ )}
+
+
+
+
+
+ {diffSummary(conflict)}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ gap: spacing.sm,
+ },
+ compareGrid: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ sideCard: {
+ flex: 1,
+ minWidth: 0,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgTertiary,
+ padding: spacing.sm,
+ gap: spacing.xs,
+ },
+ sideCardActive: {
+ borderColor: colors.accent,
+ },
+ sideCardDimmed: {
+ opacity: 0.48,
+ },
+ sideHead: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ },
+ sideTitle: {
+ flex: 1,
+ minWidth: 0,
+ },
+ trackRows: {
+ gap: spacing.xs,
+ marginTop: spacing.xs,
+ },
+ trackGroup: {
+ gap: spacing.xs,
+ },
+ sectionLabel: {
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ },
+ trackRow: {
+ minHeight: 42,
+ borderRadius: radius.sm,
+ backgroundColor: colors.bgSecondary,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ },
+ trackRowAdded: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.accent,
+ },
+ trackRowSeparate: {
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ },
+ trackRowRemoved: {
+ opacity: 0.46,
+ },
+ trackText: {
+ flex: 1,
+ minWidth: 0,
+ },
+ trackBadge: {
+ maxWidth: 104,
+ },
+ summaryBlock: {
+ borderRadius: radius.sm,
+ backgroundColor: colors.bgTertiary,
+ padding: spacing.sm,
+ gap: spacing.xs,
+ },
+});
diff --git a/src/components/sync/SyncConflictPrompt.tsx b/src/components/sync/SyncConflictPrompt.tsx
new file mode 100644
index 0000000..a9c53b8
--- /dev/null
+++ b/src/components/sync/SyncConflictPrompt.tsx
@@ -0,0 +1,314 @@
+// Root-mounted popup for desktop-sync conflicts: fires the moment a sync run
+// detects NEW conflicts (auto or manual) instead of waiting for the user to
+// wander into Settings. The once-per-session bookkeeping lives in
+// desktopSyncStore (conflictPromptVisible) so this component is a pure
+// derivation of store state — no set-state-in-effect (React Compiler rule).
+// Suppressed while the user is already on the sync screen; if they leave it
+// without resolving, the one pending reminder still shows.
+
+import { useState } from 'react';
+import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { router, usePathname } from 'expo-router';
+import { Text } from '@/components/Text';
+import { colors, radius, spacing } from '@/theme';
+import { formatRelativeTime } from '@/lib/format';
+import {
+ buildSyncConflictResolutionPreview,
+ buildSyncPlaylistEntryDiff,
+ syncPlaylistToSnapshot,
+} from '@/shared/sync/conflictPreview';
+import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
+import type {
+ DesktopSyncConflictResolution,
+ DesktopSyncPlaylistConflict,
+ SyncPlaylistSnapshot,
+} from '@/types/desktopSync';
+
+const RESOLUTION_LABELS: Record = {
+ desktop: 'Use desktop version',
+ phone: 'Use phone version',
+ both: 'Keep both playlists',
+ merge: 'Combine songs',
+};
+
+function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] {
+ return conflict.playlistKind === 'dynamic'
+ ? ['desktop', 'phone', 'both']
+ : ['desktop', 'phone', 'both', 'merge'];
+}
+
+function sideSubtitle(snapshot: SyncPlaylistSnapshot): string {
+ const count = snapshot.kind === 'dynamic'
+ ? 'Dynamic playlist'
+ : `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`;
+ return `${count} · edited ${formatRelativeTime(snapshot.updatedAt)}`;
+}
+
+function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): string {
+ if (desktop.kind !== 'normal' || phone.kind !== 'normal') {
+ return desktop.dynamicRules === phone.dynamicRules
+ ? 'The playlist details do not match.'
+ : 'The playlist rules do not match.';
+ }
+
+ const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries);
+ const parts = [
+ diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null,
+ diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null,
+ diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null,
+ ].filter((part): part is string => part !== null);
+ return parts.length > 0 ? parts.join(' · ') : 'The playlists have the same songs.';
+}
+
+export function SyncConflictPrompt() {
+ const conflicts = useDesktopSyncStore((s) => s.conflicts);
+ const status = useDesktopSyncStore((s) => s.status);
+ const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible);
+ const dismissConflictPrompt = useDesktopSyncStore((s) => s.dismissConflictPrompt);
+ const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
+ const pathname = usePathname();
+ const [choice, setChoice] = useState<{
+ syncUid: string;
+ resolution: DesktopSyncConflictResolution;
+ } | null>(null);
+
+ const visible = promptVisible && conflicts.length > 0 && pathname !== '/desktop-sync';
+ if (!visible) return null;
+
+ const count = conflicts.length;
+ const firstConflict = conflicts[0];
+ const busy = status === 'syncing';
+ const desktopSnapshot = syncPlaylistToSnapshot(firstConflict.remote);
+ const phoneSnapshot = syncPlaylistToSnapshot(firstConflict.local);
+ const options = resolutionOptions(firstConflict);
+ const selectedResolution = choice?.syncUid === firstConflict.syncUid && options.includes(choice.resolution)
+ ? choice.resolution
+ : null;
+ const preview = selectedResolution
+ ? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot)
+ : null;
+
+ const review = () => {
+ dismissConflictPrompt();
+ router.push('/desktop-sync' as never);
+ };
+
+ const confirm = () => {
+ if (!selectedResolution || busy) return;
+ dismissConflictPrompt();
+ void resolveConflict(firstConflict, selectedResolution);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Sync conflict{count === 1 ? '' : 's'}
+
+ {count > 1 ? (
+
+ 1 of {count}
+
+ ) : null}
+
+
+
+ “{firstConflict.localName}” is different on desktop and this phone.
+
+
+
+
+ Desktop
+
+
+ {desktopSnapshot.name}
+
+
+ {sideSubtitle(desktopSnapshot)}
+
+
+
+
+ This phone
+
+
+ {phoneSnapshot.name}
+
+
+ {sideSubtitle(phoneSnapshot)}
+
+
+
+
+ {diffLine(desktopSnapshot, phoneSnapshot)}
+
+
+ {options.map((resolution) => (
+ setChoice({ syncUid: firstConflict.syncUid, resolution })}
+ >
+ {RESOLUTION_LABELS[resolution]}
+
+ ))}
+
+
+
+ {preview ? preview.title : 'Choose what should happen'}
+
+
+ {preview
+ ? preview.detail
+ : 'Nothing changes until you confirm.'}
+
+
+ {count > 1 ? (
+
+
+ Review all {count} conflicts
+
+
+ ) : null}
+
+
+
+
+ Not now
+
+
+
+
+ Confirm
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 400,
+ maxHeight: '88%',
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgSecondary,
+ padding: spacing.lg,
+ gap: spacing.sm,
+ },
+ scrollContent: {
+ gap: spacing.md,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ },
+ titleBlock: {
+ flex: 1,
+ },
+ title: {
+ flex: 1,
+ },
+ body: {
+ lineHeight: 21,
+ },
+ sideSummaryGrid: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ sideSummaryCard: {
+ flex: 1,
+ minWidth: 0,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgTertiary,
+ padding: spacing.sm,
+ gap: 2,
+ },
+ choiceList: {
+ gap: spacing.sm,
+ },
+ choiceRow: {
+ minHeight: 42,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgTertiary,
+ paddingHorizontal: spacing.md,
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ },
+ choiceRowSelected: {
+ 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,
+ },
+ reviewLink: {
+ alignSelf: 'flex-start',
+ },
+ actions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: spacing.sm,
+ marginTop: spacing.xs,
+ },
+ secondaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ paddingHorizontal: spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ primaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ backgroundColor: colors.accent,
+ paddingHorizontal: spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+});
diff --git a/src/db/desktopSyncQueries.ts b/src/db/desktopSyncQueries.ts
new file mode 100644
index 0000000..d8efbf9
--- /dev/null
+++ b/src/db/desktopSyncQueries.ts
@@ -0,0 +1,454 @@
+// Mobile-side DB surface for the desktop LAN sync (src/services/desktopSync.ts).
+// Serializes local favorites/playlists into the shared wire vocabulary and
+// applies merged results. Apply-variants deliberately use the caller-supplied
+// (source) timestamps and never write tombstones for the rows they touch —
+// otherwise an applied change would look like a fresh local edit on the next
+// sync and ping-pong between devices.
+
+import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity';
+import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist';
+import { randomSaltHex } from '@/lib/hash';
+import type {
+ SyncFavorite,
+ SyncPlaylist,
+ SyncPlaylistEntry,
+ SyncPlaylistKind,
+} from '@/types/desktopSync';
+import type { LibraryDatabase } from './database';
+import {
+ decodedDocPath,
+ matchSyncEntry,
+ type ImportMatchIndex,
+} from '@/library/playlistFiles';
+
+export interface LocalSyncFavorite extends SyncFavorite {
+ /** Local favorite rows carrying this identity (empty for pending rows). */
+ trackPaths: string[];
+ pending: boolean;
+}
+
+export interface LocalSyncPlaylist {
+ id: number;
+ syncUid: string;
+ name: string;
+ kind: SyncPlaylistKind;
+ dynamicRules: string | null;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface LocalSyncState {
+ favorites: Map;
+ favoriteTombstones: Map;
+ playlists: LocalSyncPlaylist[];
+ playlistTombstones: Map;
+}
+
+/** Assign a sync identity to every sync-eligible playlist that lacks one.
+ * Assigning identity is not an edit: updated_at stays untouched. */
+export async function ensurePlaylistSyncUids(db: LibraryDatabase): Promise {
+ const rows = await db.all<{ id: number }>(
+ 'SELECT id FROM playlists WHERE sync_uid IS NULL AND remote_source_id IS NULL'
+ );
+ for (const row of rows) {
+ await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [randomSaltHex(16), row.id]);
+ }
+}
+
+export async function getLocalSyncState(db: LibraryDatabase): Promise {
+ const favorites = new Map();
+ const favoriteRows = await db.all<{
+ track_path: string;
+ added_at: number;
+ title: string | null;
+ artist: string | null;
+ album: string | null;
+ }>(`
+ SELECT f.track_path, f.added_at, t.title, t.artist, t.album
+ FROM favorites f
+ LEFT JOIN tracks t ON t.path = f.track_path
+ `);
+ for (const row of favoriteRows) {
+ // Orphaned favorites (no track row) have no metadata identity — skip.
+ if (row.title == null || !normalizeSyncKeyPart(row.title)) continue;
+ const key = buildTrackSyncKey(row.title, row.artist ?? '', row.album ?? '');
+ const existing = favorites.get(key);
+ if (existing) {
+ existing.trackPaths.push(row.track_path);
+ if (row.added_at > existing.addedAt) existing.addedAt = row.added_at;
+ } else {
+ favorites.set(key, {
+ key,
+ title: row.title,
+ artist: row.artist ?? '',
+ album: row.album ?? '',
+ addedAt: row.added_at,
+ trackPaths: [row.track_path],
+ pending: false,
+ });
+ }
+ }
+
+ // Pending favorites re-enter sync state so they keep propagating even while
+ // unresolved locally.
+ const pendingRows = await db.all<{
+ sync_key: string;
+ title: string;
+ artist: string;
+ album: string;
+ added_at: number;
+ }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending');
+ for (const row of pendingRows) {
+ if (favorites.has(row.sync_key)) continue;
+ favorites.set(row.sync_key, {
+ key: row.sync_key,
+ title: row.title,
+ artist: row.artist,
+ album: row.album,
+ addedAt: row.added_at,
+ trackPaths: [],
+ pending: true,
+ });
+ }
+
+ const favoriteTombstones = new Map();
+ for (const row of await db.all<{ sync_key: string; deleted_at: number }>(
+ 'SELECT sync_key, deleted_at FROM favorite_tombstones'
+ )) {
+ favoriteTombstones.set(row.sync_key, row.deleted_at);
+ }
+
+ const playlists: LocalSyncPlaylist[] = [];
+ for (const row of await db.all<{
+ id: number;
+ sync_uid: string;
+ name: string;
+ kind: string | null;
+ dynamic_rules_json: string | null;
+ created_at: number;
+ updated_at: number;
+ }>(`
+ SELECT id, sync_uid, name, kind, dynamic_rules_json, created_at, updated_at
+ FROM playlists
+ WHERE sync_uid IS NOT NULL AND remote_source_id IS NULL
+ `)) {
+ const kind: SyncPlaylistKind = row.kind === 'dynamic' ? 'dynamic' : 'normal';
+ playlists.push({
+ id: row.id,
+ syncUid: row.sync_uid,
+ name: row.name,
+ kind,
+ dynamicRules: kind === 'dynamic' ? row.dynamic_rules_json : null,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ });
+ }
+
+ const playlistTombstones = new Map();
+ for (const row of await db.all<{ sync_uid: string; deleted_at: number }>(
+ 'SELECT sync_uid, deleted_at FROM playlist_tombstones'
+ )) {
+ playlistTombstones.set(row.sync_uid, row.deleted_at);
+ }
+
+ return { favorites, favoriteTombstones, playlists, playlistTombstones };
+}
+
+/** Serialize a normal playlist's entries for a push to the desktop. */
+export async function getSyncPlaylistEntries(
+ db: LibraryDatabase,
+ playlistId: number
+): Promise {
+ const rows = await db.all<{
+ track_path: string;
+ position: number;
+ added_at: number;
+ fallback_title: string | null;
+ fallback_artist: string | null;
+ fallback_album: string | null;
+ title: string | null;
+ artist: string | null;
+ album: string | null;
+ duration: number | null;
+ file_name: string | null;
+ }>(`
+ SELECT pt.track_path, pt.position, pt.added_at,
+ pt.fallback_title, pt.fallback_artist, pt.fallback_album,
+ t.title, t.artist, t.album, t.duration, t.file_name
+ FROM playlist_tracks pt
+ LEFT JOIN tracks t ON t.path = pt.track_path
+ WHERE pt.playlist_id = ?
+ ORDER BY pt.position, pt.id
+ `, [playlistId]);
+
+ return rows.map((row) => ({
+ title: row.title ?? row.fallback_title ?? '',
+ artist: row.artist ?? row.fallback_artist ?? '',
+ album: row.album ?? row.fallback_album ?? '',
+ durationSeconds: typeof row.duration === 'number' && row.duration > 0 ? row.duration : null,
+ position: row.position,
+ addedAt: row.added_at,
+ // The peer can only use trailing path segments; send the decoded SAF path
+ // when the track exists locally, else pass the stored (foreign) path along.
+ sourcePath: row.title != null
+ ? (decodedDocPath(row.track_path) ?? row.file_name ?? null)
+ : row.track_path || null,
+ }));
+}
+
+export async function upsertPendingFavorite(db: LibraryDatabase, item: SyncFavorite): Promise {
+ await db.run(
+ 'INSERT OR REPLACE INTO favorite_sync_pending (sync_key, title, artist, album, added_at) VALUES (?, ?, ?, ?, ?)',
+ [item.key, item.title, item.artist, item.album, item.addedAt]
+ );
+}
+
+/** Retry pending favorites against the matching ladder; promoted rows keep
+ * their original added_at. Returns the number promoted. */
+export async function resolvePendingFavorites(
+ db: LibraryDatabase,
+ index: ImportMatchIndex
+): Promise {
+ const rows = await db.all<{
+ sync_key: string;
+ title: string;
+ artist: string;
+ album: string;
+ added_at: number;
+ }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending');
+ let resolved = 0;
+ for (const row of rows) {
+ const match = matchSyncEntry({ title: row.title, artist: row.artist, album: row.album }, index);
+ if (match.kind !== 'matched') continue;
+ await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
+ match.track.path,
+ row.added_at,
+ ]);
+ await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [row.sync_key]);
+ resolved += 1;
+ }
+ return resolved;
+}
+
+export async function applySyncedFavoriteAdd(
+ db: LibraryDatabase,
+ trackPath: string,
+ syncKey: string,
+ addedAt: number
+): Promise {
+ await db.run('INSERT OR REPLACE INTO favorites (track_path, added_at) VALUES (?, ?)', [
+ trackPath,
+ addedAt,
+ ]);
+ await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]);
+ await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]);
+}
+
+export async function applySyncedFavoriteRemove(
+ db: LibraryDatabase,
+ trackPaths: readonly string[],
+ syncKey: string,
+ deletedAt: number
+): Promise {
+ for (const trackPath of trackPaths) {
+ await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]);
+ }
+ await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]);
+ await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [
+ syncKey,
+ deletedAt,
+ ]);
+}
+
+export async function removeFavoriteTombstone(db: LibraryDatabase, syncKey: string): Promise {
+ await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]);
+}
+
+/** Link a local playlist to the desktop's identity (first-sync name pairing).
+ * Must NOT bump updated_at — adopting identity is not an edit. */
+export async function adoptPlaylistSyncUid(
+ db: LibraryDatabase,
+ playlistId: number,
+ syncUid: string
+): Promise {
+ await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [syncUid, playlistId]);
+}
+
+export async function removePlaylistTombstone(db: LibraryDatabase, syncUid: string): Promise {
+ await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [syncUid]);
+}
+
+/** Create-or-replace a playlist by sync_uid from desktop state (whole-playlist
+ * last-writer-wins). Returns per-entry match counts for the sync summary. */
+export async function replaceSyncedPlaylist(
+ db: LibraryDatabase,
+ input: SyncPlaylist,
+ index: ImportMatchIndex
+): Promise<{ status: 'created' | 'replaced' | 'skipped-incompatible'; entriesMatched: number; entriesFallback: number }> {
+ const kind: SyncPlaylistKind = input.kind === 'dynamic' ? 'dynamic' : 'normal';
+ let rulesJson: string | null = null;
+ if (kind === 'dynamic') {
+ try {
+ rulesJson = JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(input.dynamicRules ?? '')));
+ } catch {
+ return { status: 'skipped-incompatible', entriesMatched: 0, entriesFallback: 0 };
+ }
+ }
+
+ const existing = await db.get<{ id: number }>('SELECT id FROM playlists WHERE sync_uid = ?', [
+ input.syncUid,
+ ]);
+ let playlistId: number;
+ let created = false;
+ if (existing) {
+ playlistId = existing.id;
+ await db.run(
+ 'UPDATE playlists SET name = ?, kind = ?, dynamic_rules_json = ?, updated_at = ? WHERE id = ?',
+ [input.name, kind, rulesJson, input.updatedAt, playlistId]
+ );
+ await db.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlistId]);
+ } else {
+ const result = await db.run(
+ `INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [input.name, kind, rulesJson, input.createdAt, input.updatedAt, input.syncUid]
+ );
+ playlistId = result.lastInsertRowid;
+ created = true;
+ }
+ await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [input.syncUid]);
+
+ let entriesMatched = 0;
+ let entriesFallback = 0;
+ if (kind === 'normal' && Array.isArray(input.entries)) {
+ const orderedEntries = [...input.entries].sort((a, b) => a.position - b.position);
+ const seenTrackPaths = new Set();
+ let position = 0;
+ for (const entry of orderedEntries) {
+ const match = matchSyncEntry(
+ { title: entry.title, artist: entry.artist, album: entry.album, sourcePath: entry.sourcePath },
+ index
+ );
+ let trackPath: string;
+ let matched = false;
+ if (match.kind === 'matched') {
+ trackPath = match.track.path;
+ matched = true;
+ } else {
+ const sourcePath = entry.sourcePath?.trim();
+ trackPath = sourcePath || `astra-sync://unmatched/${buildTrackSyncKey(entry.title, entry.artist, entry.album)}`;
+ }
+ if (seenTrackPaths.has(trackPath)) continue;
+ seenTrackPaths.add(trackPath);
+ await db.run(
+ `INSERT INTO playlist_tracks
+ (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [
+ playlistId,
+ trackPath,
+ position++,
+ entry.addedAt > 0 ? entry.addedAt : input.updatedAt,
+ matched ? null : entry.title || null,
+ matched ? null : entry.artist || null,
+ matched ? null : entry.album || null,
+ ]
+ );
+ if (matched) {
+ entriesMatched += 1;
+ } else {
+ entriesFallback += 1;
+ }
+ }
+ }
+
+ return { status: created ? 'created' : 'replaced', entriesMatched, entriesFallback };
+}
+
+export async function applySyncedPlaylistDelete(
+ db: LibraryDatabase,
+ syncUid: string,
+ deletedAt: number
+): Promise {
+ // ON DELETE CASCADE removes the entries.
+ await db.run('DELETE FROM playlists WHERE sync_uid = ?', [syncUid]);
+ await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [
+ syncUid,
+ deletedAt,
+ ]);
+ await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]);
+}
+
+// --- Conflict-detection baseline (playlist_sync_state) -----------------------
+// The (local, remote) updated_at pair from the last successful sync per
+// playlist. With a baseline, sync direction comes from which side changed —
+// not the clock — and both-changed becomes a user-facing conflict.
+
+export interface PlaylistSyncBaseline {
+ localUpdatedAt: number;
+ remoteUpdatedAt: number;
+}
+
+export async function getPlaylistSyncBaselines(
+ db: LibraryDatabase
+): Promise