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,
},
});
+173
View File
@@ -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) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
const playItem = useCallback(
(item: DesktopRemoteQueueItem) => {
if (item.isCurrent) return;
void useDesktopRemoteStore.getState().playQueueItem(item.queueId);
onClose();
},
[onClose]
);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<DesktopRemoteQueueItem>) => (
<Pressable
style={({ pressed }) => [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`
}
>
<View style={styles.rowText}>
<Text
variant="body"
numberOfLines={1}
style={item.isCurrent ? styles.titleActive : undefined}
>
{item.title || 'Unknown title'}
</Text>
<Text variant="label" numberOfLines={1} color={colors.textTertiary}>
{item.artist || 'Unknown artist'}
</Text>
</View>
{item.isCurrent ? (
<Ionicons name="volume-high" size={18} color={colors.accent} />
) : item.durationSeconds !== null ? (
<Text variant="label" color={colors.textTertiary}>
{formatDuration(item.durationSeconds)}
</Text>
) : null}
</Pressable>
),
[playItem]
);
return (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<View style={styles.headerRow}>
<Text variant="heading">Desktop queue</Text>
<Text variant="label" color={colors.textTertiary}>
{upcomingCount === 1 ? '1 song up next' : `${upcomingCount} songs up next`}
</Text>
</View>
<FlashList
data={items}
keyExtractor={(item) => item.queueId}
renderScrollComponent={renderFlashListScrollComponent}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.empty}>
<Text variant="body" color={colors.textSecondary}>
The desktop queue is empty.
</Text>
</View>
}
/>
</BottomSheet>
);
}
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',
},
});
+347
View File
@@ -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 (
<View style={[
styles.trackRow,
previewResolution === 'merge' && row.status !== 'moved' ? styles.trackRowAdded : null,
previewResolution === 'both' ? styles.trackRowSeparate : null,
previewResolution === 'desktop' && side === 'phone' ? styles.trackRowRemoved : null,
previewResolution === 'phone' && side === 'desktop' ? styles.trackRowRemoved : null,
]}>
<View style={styles.trackText}>
<Text variant="caption" numberOfLines={1}>
{row.title || 'Untitled track'}
</Text>
{subtitle ? (
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View>
{previewLabel || moveLabel ? (
<Text
variant="caption"
color={previewLabel ? colors.textSecondary : colors.textTertiary}
numberOfLines={1}
style={styles.trackBadge}
>
{previewLabel ?? moveLabel}
</Text>
) : null}
</View>
);
}
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 (
<Text variant="caption" color={colors.textTertiary}>
No songs only on {sideName}.
</Text>
);
}
return (
<View style={styles.trackRows}>
{sideOnlyRows.length > 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.sectionLabel}>
Only on {sideName}
</Text>
) : null}
{rows.map((row, index) => {
const startsMovedSection = row.status === 'moved' && rows[index - 1]?.status !== 'moved';
return (
<View key={row.key} style={styles.trackGroup}>
{startsMovedSection ? (
<Text variant="caption" color={colors.textTertiary} style={styles.sectionLabel}>
Different order
</Text>
) : null}
<TrackDiffRow row={row} side={side} previewResolution={previewResolution} />
</View>
);
})}
{hiddenCount > 0 ? (
<Text variant="caption" color={colors.textTertiary}>
+{hiddenCount} more
</Text>
) : null}
</View>
);
}
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 (
<View style={styles.container}>
<View style={styles.compareGrid}>
<View style={[
styles.sideCard,
desktopDimmed ? styles.sideCardDimmed : null,
desktopActive ? styles.sideCardActive : null,
]}>
<View style={styles.sideHead}>
<Ionicons name="desktop-outline" size={15} color={colors.textSecondary} />
<Text variant="caption" color={colors.textSecondary} numberOfLines={1} style={styles.sideTitle}>
{desktopName}
</Text>
</View>
<Text variant="caption" numberOfLines={1}>
{desktop.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSummary(desktop)}
</Text>
{isNormal ? (
<SideTrackList
side="desktop"
sideOnlyRows={desktopOnlyRows}
movedRows={movedRows}
previewResolution={previewResolution}
maxRows={maxRows}
/>
) : (
<Text variant="caption" color={colors.textTertiary} numberOfLines={3}>
{desktop.dynamicRules ?? 'No rules'}
</Text>
)}
</View>
<View style={[
styles.sideCard,
phoneDimmed ? styles.sideCardDimmed : null,
phoneActive ? styles.sideCardActive : null,
]}>
<View style={styles.sideHead}>
<Ionicons name="phone-portrait-outline" size={15} color={colors.textSecondary} />
<Text variant="caption" color={colors.textSecondary} numberOfLines={1} style={styles.sideTitle}>
This phone
</Text>
</View>
<Text variant="caption" numberOfLines={1}>
{phone.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSummary(phone)}
</Text>
{isNormal ? (
<SideTrackList
side="phone"
sideOnlyRows={phoneOnlyRows}
movedRows={movedRows}
previewResolution={previewResolution}
maxRows={maxRows}
/>
) : (
<Text variant="caption" color={colors.textTertiary} numberOfLines={3}>
{phone.dynamicRules ?? 'No rules'}
</Text>
)}
</View>
</View>
<View style={styles.summaryBlock}>
<Text variant="caption" color={colors.textSecondary}>
{diffSummary(conflict)}
</Text>
</View>
</View>
);
}
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,
},
});
+314
View File
@@ -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<DesktopSyncConflictResolution, string> = {
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 (
<Modal visible transparent animationType="fade" onRequestClose={dismissConflictPrompt}>
<View style={styles.backdrop}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={dismissConflictPrompt}
accessibilityLabel="Dismiss"
/>
<View style={styles.card}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Ionicons name="git-compare-outline" size={22} color={colors.warning} />
<View style={styles.titleBlock}>
<Text variant="heading" style={styles.title}>
Sync conflict{count === 1 ? '' : 's'}
</Text>
{count > 1 ? (
<Text variant="caption" color={colors.textTertiary}>
1 of {count}
</Text>
) : null}
</View>
</View>
<Text variant="body" color={colors.textSecondary} style={styles.body}>
{firstConflict.localName} is different on desktop and this phone.
</Text>
<View style={styles.sideSummaryGrid}>
<View style={styles.sideSummaryCard}>
<Text variant="label" color={colors.textSecondary}>
Desktop
</Text>
<Text variant="caption" numberOfLines={1}>
{desktopSnapshot.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSubtitle(desktopSnapshot)}
</Text>
</View>
<View style={styles.sideSummaryCard}>
<Text variant="label" color={colors.textSecondary}>
This phone
</Text>
<Text variant="caption" numberOfLines={1}>
{phoneSnapshot.name}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{sideSubtitle(phoneSnapshot)}
</Text>
</View>
</View>
<Text variant="caption" color={colors.textTertiary}>
{diffLine(desktopSnapshot, phoneSnapshot)}
</Text>
<View style={styles.choiceList}>
{options.map((resolution) => (
<Pressable
key={resolution}
style={[
styles.choiceRow,
selectedResolution === resolution ? styles.choiceRowSelected : null,
busy && styles.disabled,
]}
disabled={busy}
onPress={() => setChoice({ syncUid: firstConflict.syncUid, resolution })}
>
<Text variant="label">{RESOLUTION_LABELS[resolution]}</Text>
</Pressable>
))}
</View>
<View style={styles.previewBox}>
<Text variant="label">
{preview ? preview.title : 'Choose what should happen'}
</Text>
<Text variant="caption" color={colors.textSecondary}>
{preview
? preview.detail
: 'Nothing changes until you confirm.'}
</Text>
</View>
{count > 1 ? (
<Pressable onPress={review} style={styles.reviewLink}>
<Text variant="caption" color={colors.accent}>
Review all {count} conflicts
</Text>
</Pressable>
) : null}
</ScrollView>
<View style={styles.actions}>
<Pressable style={styles.secondaryButton} onPress={dismissConflictPrompt}>
<Text variant="body" color={colors.textSecondary}>
Not now
</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
disabled={!selectedResolution || busy}
onPress={confirm}
>
<Text variant="body" color={colors.accentTextStrong}>
Confirm
</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
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,
},
});
+454
View File
@@ -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<string, LocalSyncFavorite>;
favoriteTombstones: Map<string, number>;
playlists: LocalSyncPlaylist[];
playlistTombstones: Map<string, number>;
}
/** 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<void> {
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<LocalSyncState> {
const favorites = new Map<string, LocalSyncFavorite>();
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<string, number>();
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<string, number>();
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<SyncPlaylistEntry[]> {
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<void> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [syncUid, playlistId]);
}
export async function removePlaylistTombstone(db: LibraryDatabase, syncUid: string): Promise<void> {
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<string>();
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<void> {
// 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<Map<string, PlaylistSyncBaseline>> {
const result = new Map<string, PlaylistSyncBaseline>();
for (const row of await db.all<{
sync_uid: string;
local_updated_at: number;
remote_updated_at: number;
}>('SELECT sync_uid, local_updated_at, remote_updated_at FROM playlist_sync_state')) {
result.set(row.sync_uid, {
localUpdatedAt: row.local_updated_at,
remoteUpdatedAt: row.remote_updated_at,
});
}
return result;
}
export async function upsertPlaylistSyncBaseline(
db: LibraryDatabase,
syncUid: string,
localUpdatedAt: number,
remoteUpdatedAt: number
): Promise<void> {
await db.run(
'INSERT OR REPLACE INTO playlist_sync_state (sync_uid, local_updated_at, remote_updated_at) VALUES (?, ?, ?)',
[syncUid, localUpdatedAt, remoteUpdatedAt]
);
}
export async function deletePlaylistSyncBaseline(db: LibraryDatabase, syncUid: string): Promise<void> {
await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]);
}
/** Baselines are meaningless against a different desktop — cleared on forget. */
export async function clearPlaylistSyncBaselines(db: LibraryDatabase): Promise<void> {
await db.run('DELETE FROM playlist_sync_state');
}
/** "Keep both" for a concurrent edit: duplicate the local playlist (entries
* included) under a new name + fresh sync identity so both versions survive. */
export async function clonePlaylistAsLocalCopy(
db: LibraryDatabase,
playlistId: number,
newName: string
): Promise<void> {
const source = await db.get<{ kind: string | null; dynamic_rules_json: string | null }>(
'SELECT kind, dynamic_rules_json FROM playlists WHERE id = ?',
[playlistId]
);
if (!source) return;
const now = Date.now();
const result = await db.run(
`INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid)
VALUES (?, ?, ?, ?, ?, ?)`,
[newName, source.kind === 'dynamic' ? 'dynamic' : 'normal', source.dynamic_rules_json, now, now, randomSaltHex(16)]
);
await db.run(
`INSERT INTO playlist_tracks (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album)
SELECT ?, track_path, position, added_at, fallback_title, fallback_artist, fallback_album
FROM playlist_tracks WHERE playlist_id = ?`,
[result.lastInsertRowid, playlistId]
);
}
+54 -1
View File
@@ -16,6 +16,7 @@ import {
buildDynamicPlaylistOrderByClause,
buildDynamicPlaylistWhereClause,
} from './dynamicPlaylistSql';
import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity';
const PLAYLIST_SELECT = `
SELECT p.id, p.name, p.kind, p.dynamic_rules_json,
@@ -274,6 +275,19 @@ export async function renamePlaylist(db: LibraryDatabase, id: number, name: stri
}
export async function deletePlaylist(db: LibraryDatabase, id: number): Promise<void> {
// Tombstone sync-eligible playlists so the desktop LAN sync propagates the
// deletion (server-mirrored playlists are excluded from that sync and are
// deleted via raw SQL elsewhere, never through here).
const row = await db.get<{ sync_uid: string | null; remote_source_id: number | null }>(
'SELECT sync_uid, remote_source_id FROM playlists WHERE id = ?',
[id]
);
if (row?.sync_uid && row.remote_source_id == null) {
await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [
row.sync_uid,
Date.now(),
]);
}
// ON DELETE CASCADE removes the entries (foreign_keys is ON per connection).
await db.run('DELETE FROM playlists WHERE id = ?', [id]);
}
@@ -469,14 +483,41 @@ export async function getFavoritePaths(db: LibraryDatabase): Promise<string[]> {
return rows.map((row) => row.track_path);
}
/** Metadata identity key for the desktop LAN sync; null when the track is
* unknown or has no usable title. */
async function trackSyncKeyForPath(db: LibraryDatabase, trackPath: string): Promise<string | null> {
const row = await db.get<{ title: string; artist: string; album: string }>(
'SELECT title, artist, album FROM tracks WHERE path = ?',
[trackPath]
);
if (!row || !normalizeSyncKeyPart(row.title)) return null;
return buildTrackSyncKey(row.title, row.artist, row.album);
}
export async function addFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
trackPath,
Date.now(),
]);
// Re-favoriting must clear any sync deletion tombstone for the same identity.
const syncKey = await trackSyncKeyForPath(db, trackPath);
if (syncKey) {
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 removeFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
// Record a deletion tombstone so the desktop LAN sync propagates the
// unfavorite instead of resurrecting it from the desktop's copy.
const syncKey = await trackSyncKeyForPath(db, trackPath);
if (syncKey) {
await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [
syncKey,
Date.now(),
]);
await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]);
}
await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]);
}
@@ -484,14 +525,26 @@ export async function removeFavorite(db: LibraryDatabase, trackPath: string): Pr
export async function addFavoritePaths(db: LibraryDatabase, paths: string[]): Promise<void> {
if (paths.length === 0) return;
const now = Date.now();
const insertedPaths: string[] = [];
await db.transaction(async (tx) => {
for (const path of paths) {
await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
const result = await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
path,
now,
]);
if (result.changes > 0) insertedPaths.push(path);
}
});
// Only genuinely new favorites clear tombstones — the remote starred sync
// re-runs its inserts every pass and must not keep resurrecting identities
// the user unfavorited elsewhere.
for (const path of insertedPaths) {
const syncKey = await trackSyncKeyForPath(db, path);
if (syncKey) {
await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]);
await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]);
}
}
}
// --- Remote sync (Subsonic playlists/favorites) ------------------------------
+44 -2
View File
@@ -21,11 +21,18 @@
// v15 adds `album_display_artist` — the settled group artist ("Various Artists" for
// shared-artwork compilations) written by the album-identity recompute pass
// (src/library/albumIdentity.ts); the backfill itself runs from libraryStore.initialize
// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts).
// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts);
// v16 adds desktop LAN sync state (src/services/desktopSync.ts): a `sync_uid` playlist
// identity shared with the paired desktop, deletion tombstones for favorites/playlists,
// and a pending table for incoming favorites that haven't matched a local track yet;
// v17 adds `playlist_sync_state` — the per-playlist (local, remote) updated_at baseline
// from the last successful sync, which turns blind last-writer-wins into 3-way change
// detection: only-one-side-changed syncs silently, both-changed surfaces a conflict
// prompt (Steam-Cloud style) instead of silently dropping an edit.
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 15;
export const SCHEMA_VERSION = 17;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -283,6 +290,41 @@ const MIGRATIONS: readonly (readonly string[])[] = [
// v14 -> v15 — settled album display artist (see header). NULL until the
// startup recompute pass backfills it; readers fall back to album_artist/artist.
[`ALTER TABLE tracks ADD COLUMN album_display_artist TEXT`],
// v15 -> v16 — desktop LAN sync (see header). Tombstones record deletions so a
// two-way merge propagates them instead of resurrecting the row from the peer;
// favorite_sync_pending holds incoming favorites with no local track match yet
// (retried against the metadata ladder at each sync). Table shapes mirror the
// desktop's (astra src/main/services/library.ts).
[
`ALTER TABLE playlists ADD COLUMN sync_uid TEXT`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_sync_uid
ON playlists(sync_uid)
WHERE sync_uid IS NOT NULL`,
`CREATE TABLE IF NOT EXISTS favorite_tombstones (
sync_key TEXT PRIMARY KEY NOT NULL,
deleted_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS favorite_sync_pending (
sync_key TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
added_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS playlist_tombstones (
sync_uid TEXT PRIMARY KEY NOT NULL,
deleted_at INTEGER NOT NULL
)`,
],
// v16 -> v17 — desktop sync conflict detection baseline (see header). Rows are
// written only for playlists that ended a sync run in-sync on both devices.
[
`CREATE TABLE IF NOT EXISTS playlist_sync_state (
sync_uid TEXT PRIMARY KEY NOT NULL,
local_updated_at INTEGER NOT NULL,
remote_updated_at INTEGER NOT NULL
)`,
],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+12
View File
@@ -6,3 +6,15 @@ export function formatDuration(seconds: number): string {
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** Coarse "how long ago" for status lines ("just now", "5 min ago", "2 h ago"). */
export function formatRelativeTime(timestampMs: number): string {
const elapsed = Date.now() - timestampMs;
if (!Number.isFinite(elapsed) || elapsed < 60_000) return 'just now';
const minutes = Math.floor(elapsed / 60_000);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} h ago`;
const days = Math.floor(hours / 24);
return `${days} d ago`;
}
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildImportIndex, matchSyncEntry } from './importMatching.ts';
import type { DbTrack } from '../types/library.ts';
function track(overrides: Partial<DbTrack> & Pick<DbTrack, 'path' | 'title' | 'artist' | 'album' | 'file_name'>): DbTrack {
return {
id: 1,
folder_id: 1,
album_artist: null,
album_identity_key: 'k',
album_display_artist: null,
duration: 200,
track_number: null,
disc_number: null,
year: null,
genre: null,
artwork_hash: null,
format: 'flac',
sample_rate: null,
bit_depth: null,
bitrate: null,
channels: null,
codec: null,
bpm: null,
musical_key: null,
source_type: 'local',
source_id: null,
source_track_id: null,
source_path: null,
artwork_source_id: null,
size: null,
mtime: 0,
added_at: 0,
modified_at: 0,
play_count: 0,
last_played_at: null,
...overrides,
} as DbTrack;
}
function safUri(docPath: string): string {
return `content://com.android.externalstorage.documents/document/${encodeURIComponent(`primary:${docPath}`)}`;
}
const NEBULA = track({
path: safUri('Music/Nova/Drift/nebula.flac'),
title: 'Nebula',
artist: 'Nova',
album: 'Drift',
file_name: 'nebula.flac',
});
const NEBULA_LIVE = track({
path: safUri('Music/Nova/Live/nebula.flac'),
title: 'Nebula',
artist: 'Nova',
album: 'Live at Dawn',
file_name: 'nebula.flac',
});
const EMBER = track({
path: safUri('Music/Cinder/Ash/ember.flac'),
title: 'Ember',
artist: 'Cinder',
album: 'Ash',
file_name: 'ember.flac',
});
test('matches by title+artist+album with case and whitespace variance', () => {
const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]);
const match = matchSyncEntry(
{ title: ' NEBULA ', artist: 'nova', album: 'Drift ' },
index
);
assert.equal(match.kind, 'matched');
assert.equal(match.kind === 'matched' && match.track.path, NEBULA.path);
});
test('unique source-path file name wins before metadata', () => {
const index = buildImportIndex([EMBER, NEBULA]);
const match = matchSyncEntry(
{ title: 'Wrong Title', artist: 'Wrong', album: 'Wrong', sourcePath: 'D:/Music/Cinder/Ash/ember.flac' },
index
);
assert.equal(match.kind, 'matched');
assert.equal(match.kind === 'matched' && match.track.path, EMBER.path);
});
test('duplicate file names resolve by trailing path-segment overlap', () => {
const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]);
const match = matchSyncEntry(
{ title: 'Nebula', artist: 'Nova', album: 'Nonexistent', sourcePath: 'D:/Library/Nova/Live/nebula.flac' },
index
);
assert.equal(match.kind, 'matched');
assert.equal(match.kind === 'matched' && match.track.path, NEBULA_LIVE.path);
});
test('ambiguous file-name rung falls through to metadata instead of giving up', () => {
// Same file name, same trailing segment depth -> tied overlap; the
// title+artist+album rung must still resolve it.
const a = track({
path: safUri('One/song.flac'),
title: 'Song',
artist: 'A',
album: 'First',
file_name: 'song.flac',
});
const b = track({
path: safUri('Two/song.flac'),
title: 'Song',
artist: 'A',
album: 'Second',
file_name: 'song.flac',
});
const index = buildImportIndex([a, b]);
const match = matchSyncEntry(
{ title: 'Song', artist: 'A', album: 'Second', sourcePath: 'Z:/Elsewhere/song.flac' },
index
);
assert.equal(match.kind, 'matched');
assert.equal(match.kind === 'matched' && match.track.path, b.path);
});
test('duplicate metadata identity reports ambiguous, not a guess', () => {
const a = track({
path: safUri('One/x.flac'),
title: 'Twin',
artist: 'Dup',
album: 'Same',
file_name: 'x.flac',
});
const b = track({
path: safUri('Two/y.flac'),
title: 'Twin',
artist: 'Dup',
album: 'Same',
file_name: 'y.flac',
});
const index = buildImportIndex([a, b]);
const match = matchSyncEntry({ title: 'Twin', artist: 'Dup', album: 'Same' }, index);
assert.equal(match.kind, 'ambiguous');
});
test('falls back to title+artist then title-only rungs', () => {
const index = buildImportIndex([NEBULA, EMBER]);
// Album mismatch -> title+artist rung; but 'Nebula' by 'Nova' exists twice
// in the library? No — only NEBULA here, so title+artist matches.
const byTitleArtist = matchSyncEntry({ title: 'Nebula', artist: 'Nova', album: 'Renamed Album' }, index);
assert.equal(byTitleArtist.kind, 'matched');
const byTitle = matchSyncEntry({ title: 'Ember', artist: 'Different Artist', album: '' }, index);
assert.equal(byTitle.kind, 'matched');
assert.equal(byTitle.kind === 'matched' && byTitle.track.path, EMBER.path);
});
test('no title means no identity', () => {
const index = buildImportIndex([NEBULA]);
assert.equal(matchSyncEntry({ title: ' ', artist: 'Nova', album: 'Drift' }, index).kind, 'none');
});
+238
View File
@@ -0,0 +1,238 @@
// Track matching ladders (pure — node-testable, no expo imports):
// - M3U import: exact content URI -> decoded SAF path -> file name (+
// path-suffix overlap) -> metadata (title+artist -> title, unique-or-null).
// - Desktop sync: source-path file name -> title+artist+album (shared
// whitespace-collapsing normalization) -> the M3U metadata rungs.
// Ported in spirit from the desktop playlist importer (library.ts).
// Runtime imports are relative (not '@/') so this module runs under plain
// `node --test`; type-only '@/' imports are erased by strip-types.
import type { DbTrack } from '@/types/library';
import type { M3uEntry } from '@/lib/m3u';
import { decodedSafDocumentPath } from './folderTree.ts';
import { normalizeSyncKeyPart } from '../shared/sync/identity.ts';
export const decodedDocPath = decodedSafDocumentPath;
interface IndexedTrack {
track: DbTrack;
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
decodedPath: string | null;
}
export interface ImportMatchIndex {
byContentUri: Map<string, DbTrack>;
byDecodedPath: Map<string, DbTrack>;
byFileName: Map<string, IndexedTrack[]>;
/** Metadata maps are unique-or-null: null marks a collision (ambiguous). */
byTitleArtist: Map<string, DbTrack | null>;
byTitle: Map<string, DbTrack | null>;
/** Desktop-sync rung, keyed with the shared whitespace-collapsing
* normalization (normalizeSyncKeyPart) unlike the M3U maps above. */
byTitleArtistAlbum: Map<string, DbTrack | null>;
}
function upsertUnique(map: Map<string, DbTrack | null>, key: string, track: DbTrack): void {
if (!key) return;
map.set(key, map.has(key) ? null : track);
}
export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex {
const index: ImportMatchIndex = {
byContentUri: new Map(),
byDecodedPath: new Map(),
byFileName: new Map(),
byTitleArtist: new Map(),
byTitle: new Map(),
byTitleArtistAlbum: new Map(),
};
for (const track of tracks) {
index.byContentUri.set(track.path, track);
const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null;
if (decoded && !index.byDecodedPath.has(decoded)) {
index.byDecodedPath.set(decoded, track);
}
const fileName = track.file_name.toLocaleLowerCase();
const bucket = index.byFileName.get(fileName);
if (bucket) {
bucket.push({ track, decodedPath: decoded });
} else {
index.byFileName.set(fileName, [{ track, decodedPath: decoded }]);
}
const title = track.title.trim().toLocaleLowerCase();
const artist = track.artist.trim().toLocaleLowerCase();
upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track);
upsertUnique(index.byTitle, title, track);
const syncTitle = normalizeSyncKeyPart(track.title);
if (syncTitle) {
upsertUnique(
index.byTitleArtistAlbum,
`${syncTitle}\n${normalizeSyncKeyPart(track.artist)}\n${normalizeSyncKeyPart(track.album)}`,
track
);
}
}
return index;
}
/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */
export function normalizeEntryPath(path: string): string {
let value = path.trim().replace(/\\/g, '/');
if (/^file:\/\//i.test(value)) value = value.slice('file://'.length);
if (value.includes('%')) {
try {
value = decodeURIComponent(value);
} catch {
// keep the raw value
}
}
return value;
}
export type ImportMatch =
| { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' }
| { kind: 'ambiguous' }
| { kind: 'none' };
/** Trailing path-segment overlap between an entry path and a track's decoded path. */
function suffixOverlap(entrySegments: string[], decodedPath: string | null): number {
if (!decodedPath) return 1; // file name matched, nothing more to compare
const trackSegments = decodedPath.split('/');
let overlap = 0;
while (
overlap < entrySegments.length &&
overlap < trackSegments.length &&
entrySegments[entrySegments.length - 1 - overlap] ===
trackSegments[trackSegments.length - 1 - overlap]
) {
overlap += 1;
}
return overlap;
}
export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch {
const raw = entry.path.trim();
// 1. Exact SAF content URI (our own exports never write these, but be safe).
const exact = index.byContentUri.get(raw);
if (exact) return { kind: 'matched', track: exact, via: 'path' };
const normalized = normalizeEntryPath(raw).toLocaleLowerCase();
// 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format).
const byPath = index.byDecodedPath.get(normalized);
if (byPath) return { kind: 'matched', track: byPath, via: 'path' };
// 3. File name bucket, disambiguated by longest trailing-segment overlap.
const entrySegments = normalized.split('/');
const fileName = entrySegments[entrySegments.length - 1];
const candidates = index.byFileName.get(fileName) ?? [];
if (candidates.length === 1) {
return { kind: 'matched', track: candidates[0].track, via: 'path' };
}
if (candidates.length > 1) {
let best: IndexedTrack | null = null;
let bestScore = 0;
let tied = false;
for (const candidate of candidates) {
const score = suffixOverlap(entrySegments, candidate.decodedPath);
if (score > bestScore) {
best = candidate;
bestScore = score;
tied = false;
} else if (score === bestScore) {
tied = true;
}
}
if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' };
return { kind: 'ambiguous' };
}
// 4. Metadata from EXTINF: title+artist, then title (unique-or-null).
const title = entry.title?.trim().toLocaleLowerCase();
if (title) {
const artist = entry.artist?.trim().toLocaleLowerCase();
if (artist) {
const hit = index.byTitleArtist.get(`${title}\n${artist}`);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
const hit = index.byTitle.get(title);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
return { kind: 'none' };
}
export interface SyncEntryQuery {
title: string;
artist: string;
album: string;
/** The peer's file path, useful only for its file name / trailing segments. */
sourcePath?: string | null;
}
/**
* Desktop-sync matching ladder: source-path file name (unique or best
* suffix-overlap) -> title+artist+album -> title+artist -> title. Unlike M3U
* import, sync entries always carry metadata, so an ambiguous file-name rung
* falls through to metadata instead of giving up.
*/
export function matchSyncEntry(query: SyncEntryQuery, index: ImportMatchIndex): ImportMatch {
const sourcePath = query.sourcePath?.trim();
if (sourcePath) {
const normalized = normalizeEntryPath(sourcePath).toLocaleLowerCase();
const entrySegments = normalized.split('/');
const fileName = entrySegments[entrySegments.length - 1];
const candidates = index.byFileName.get(fileName) ?? [];
if (candidates.length === 1) {
return { kind: 'matched', track: candidates[0].track, via: 'path' };
}
if (candidates.length > 1) {
let best: DbTrack | null = null;
let bestScore = 0;
let tied = false;
for (const candidate of candidates) {
const score = suffixOverlap(entrySegments, candidate.decodedPath);
if (score > bestScore) {
best = candidate.track;
bestScore = score;
tied = false;
} else if (score === bestScore) {
tied = true;
}
}
if (best && !tied) return { kind: 'matched', track: best, via: 'path' };
}
}
const title = normalizeSyncKeyPart(query.title);
if (!title) return { kind: 'none' };
const artist = normalizeSyncKeyPart(query.artist);
const album = normalizeSyncKeyPart(query.album);
if (artist && album) {
const hit = index.byTitleArtistAlbum.get(`${title}\n${artist}\n${album}`);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
// The M3U maps use plain trim+lowercase keys — query them the same way.
const m3uTitle = query.title.trim().toLocaleLowerCase();
const m3uArtist = query.artist.trim().toLocaleLowerCase();
if (m3uArtist) {
const hit = index.byTitleArtist.get(`${m3uTitle}\n${m3uArtist}`);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
const hit = index.byTitle.get(m3uTitle);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
return { kind: 'none' };
}
+13 -151
View File
@@ -1,7 +1,6 @@
// M3U file IO (SAF export, document-picker import) + the import matching
// ladder: exact content URI -> decoded SAF path -> file name (+ path-suffix
// overlap) -> metadata (title+artist -> title, unique-or-null), ported in
// spirit from the desktop playlist importer.
// M3U file IO (SAF export, document-picker import). The matching ladders live
// in importMatching.ts (pure, node-testable) and are re-exported here for the
// existing import sites.
import * as DocumentPicker from 'expo-document-picker';
import {
@@ -9,155 +8,18 @@ import {
readAsStringAsync,
writeAsStringAsync,
} from 'expo-file-system/legacy';
import type { DbTrack } from '@/types/library';
import { decodedSafDocumentPath } from './folderTree';
import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u';
export const decodedDocPath = decodedSafDocumentPath;
// --- Import matching ---------------------------------------------------------
interface IndexedTrack {
track: DbTrack;
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
decodedPath: string | null;
}
export interface ImportMatchIndex {
byContentUri: Map<string, DbTrack>;
byDecodedPath: Map<string, DbTrack>;
byFileName: Map<string, IndexedTrack[]>;
/** Metadata maps are unique-or-null: null marks a collision (ambiguous). */
byTitleArtist: Map<string, DbTrack | null>;
byTitle: Map<string, DbTrack | null>;
}
function upsertUnique(map: Map<string, DbTrack | null>, key: string, track: DbTrack): void {
if (!key) return;
map.set(key, map.has(key) ? null : track);
}
export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex {
const index: ImportMatchIndex = {
byContentUri: new Map(),
byDecodedPath: new Map(),
byFileName: new Map(),
byTitleArtist: new Map(),
byTitle: new Map(),
};
for (const track of tracks) {
index.byContentUri.set(track.path, track);
const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null;
if (decoded && !index.byDecodedPath.has(decoded)) {
index.byDecodedPath.set(decoded, track);
}
const fileName = track.file_name.toLocaleLowerCase();
const bucket = index.byFileName.get(fileName);
if (bucket) {
bucket.push({ track, decodedPath: decoded });
} else {
index.byFileName.set(fileName, [{ track, decodedPath: decoded }]);
}
const title = track.title.trim().toLocaleLowerCase();
const artist = track.artist.trim().toLocaleLowerCase();
upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track);
upsertUnique(index.byTitle, title, track);
}
return index;
}
/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */
export function normalizeEntryPath(path: string): string {
let value = path.trim().replace(/\\/g, '/');
if (/^file:\/\//i.test(value)) value = value.slice('file://'.length);
if (value.includes('%')) {
try {
value = decodeURIComponent(value);
} catch {
// keep the raw value
}
}
return value;
}
export type ImportMatch =
| { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' }
| { kind: 'ambiguous' }
| { kind: 'none' };
/** Trailing path-segment overlap between an entry path and a track's decoded path. */
function suffixOverlap(entrySegments: string[], decodedPath: string | null): number {
if (!decodedPath) return 1; // file name matched, nothing more to compare
const trackSegments = decodedPath.split('/');
let overlap = 0;
while (
overlap < entrySegments.length &&
overlap < trackSegments.length &&
entrySegments[entrySegments.length - 1 - overlap] ===
trackSegments[trackSegments.length - 1 - overlap]
) {
overlap += 1;
}
return overlap;
}
export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch {
const raw = entry.path.trim();
// 1. Exact SAF content URI (our own exports never write these, but be safe).
const exact = index.byContentUri.get(raw);
if (exact) return { kind: 'matched', track: exact, via: 'path' };
const normalized = normalizeEntryPath(raw).toLocaleLowerCase();
// 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format).
const byPath = index.byDecodedPath.get(normalized);
if (byPath) return { kind: 'matched', track: byPath, via: 'path' };
// 3. File name bucket, disambiguated by longest trailing-segment overlap.
const entrySegments = normalized.split('/');
const fileName = entrySegments[entrySegments.length - 1];
const candidates = index.byFileName.get(fileName) ?? [];
if (candidates.length === 1) {
return { kind: 'matched', track: candidates[0].track, via: 'path' };
}
if (candidates.length > 1) {
let best: IndexedTrack | null = null;
let bestScore = 0;
let tied = false;
for (const candidate of candidates) {
const score = suffixOverlap(entrySegments, candidate.decodedPath);
if (score > bestScore) {
best = candidate;
bestScore = score;
tied = false;
} else if (score === bestScore) {
tied = true;
}
}
if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' };
return { kind: 'ambiguous' };
}
// 4. Metadata from EXTINF: title+artist, then title (unique-or-null).
const title = entry.title?.trim().toLocaleLowerCase();
if (title) {
const artist = entry.artist?.trim().toLocaleLowerCase();
if (artist) {
const hit = index.byTitleArtist.get(`${title}\n${artist}`);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
const hit = index.byTitle.get(title);
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
if (hit === null) return { kind: 'ambiguous' };
}
return { kind: 'none' };
}
export {
buildImportIndex,
decodedDocPath,
matchImportEntry,
matchSyncEntry,
normalizeEntryPath,
type ImportMatch,
type ImportMatchIndex,
type SyncEntryQuery,
} from './importMatching';
// --- File IO -------------------------------------------------------------------
+87 -3
View File
@@ -6,7 +6,14 @@ import type {
DesktopRemotePairingClaim,
DesktopRemotePairingStatus,
DesktopRemotePinPairingRequest,
DesktopRemoteQueueSnapshot,
} from '@/types/desktopRemote';
import type {
DesktopSyncApplyPayload,
DesktopSyncApplyResult,
DesktopSyncConflictReportPayload,
DesktopSyncState,
} from '@/types/desktopSync';
export {
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
@@ -102,6 +109,10 @@ function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null {
typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
? candidate.protocolVersion
: 1,
syncRequestedAt:
typeof candidate.syncRequestedAt === 'number' && Number.isFinite(candidate.syncRequestedAt)
? candidate.syncRequestedAt
: null,
};
}
@@ -234,8 +245,63 @@ export async function sendDesktopRemoteControl(
});
}
export async function sendDesktopRemotePlayQueueItem(
baseUrl: string,
token: string,
queueId: string
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
body: { command: 'play-queue-item', queueId },
});
}
export async function fetchDesktopRemoteQueue(
baseUrl: string,
token: string
): Promise<DesktopRemoteQueueSnapshot> {
return fetchJson<DesktopRemoteQueueSnapshot>(baseUrl, '/v1/queue', { token });
}
// ── Favorites/playlists LAN sync (protocolVersion >= 2) ──────────────────────
// Sync payloads can carry thousands of favorites/playlist entries, so both
// calls get generous timeouts compared to the 8 s control default.
export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 });
}
export async function postDesktopSyncApply(
baseUrl: string,
token: string,
payload: DesktopSyncApplyPayload
): Promise<DesktopSyncApplyResult> {
return fetchJson<DesktopSyncApplyResult>(baseUrl, '/v1/sync/apply', {
method: 'POST',
token,
body: payload,
timeoutMs: 60_000,
});
}
export async function postDesktopSyncConflicts(
baseUrl: string,
token: string,
payload: DesktopSyncConflictReportPayload
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', {
method: 'POST',
token,
body: payload,
});
}
export type DesktopRemoteSseHandlers = {
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void;
onQueue?: (queue: DesktopRemoteQueueSnapshot) => void;
/** Desktop-initiated library-sync nudge (user clicked Sync Now / resolved a conflict there). */
onSyncRequest?: () => void;
onUnauthorized: () => void;
onDisconnect: () => void;
onError?: (error: unknown) => void;
@@ -244,7 +310,9 @@ export type DesktopRemoteSseHandlers = {
function processSseChunk(
buffer: { value: string },
chunk: string,
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void,
onQueue?: (queue: DesktopRemoteQueueSnapshot) => void,
onSyncRequest?: () => void
): void {
buffer.value += chunk.replace(/\r/g, '');
let boundary = buffer.value.indexOf('\n\n');
@@ -267,6 +335,14 @@ function processSseChunk(
} catch {
// Ignore a malformed event; polling/reconnect will correct the UI.
}
} else if (eventName === 'queue' && data.length > 0 && onQueue) {
try {
onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot);
} catch {
// Ignore a malformed event; the on-demand queue fetch will correct it.
}
} else if (eventName === 'sync-request' && onSyncRequest) {
onSyncRequest();
}
boundary = buffer.value.indexOf('\n\n');
}
@@ -307,9 +383,17 @@ export function startDesktopRemoteEventStream(
while (!closed) {
const next = await reader.read();
if (next.done) break;
if (next.value) processSseChunk(buffer, decoder.decode(next.value, { stream: true }), handlers.onSnapshot);
if (next.value) {
processSseChunk(
buffer,
decoder.decode(next.value, { stream: true }),
handlers.onSnapshot,
handlers.onQueue,
handlers.onSyncRequest
);
}
}
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot);
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest);
if (!closed) handlers.onDisconnect();
} catch (error) {
if (closed || controller.signal.aborted) return;
+678
View File
@@ -0,0 +1,678 @@
// Desktop LAN sync engine — the mobile side is the merge authority. One run:
// pull the desktop's full favorites/playlists state, merge against local state
// (two-way, deletion tombstones), apply the local half of the merge in one
// transaction, then push only the desktop-bound diff.
// Loop-prevention invariant: everything applied here uses the SOURCE
// timestamps via the desktopSyncQueries apply-variants (never Date.now(), and
// never the tombstone-writing user-mutation paths) so an applied change does
// not read as a fresh local edit on the next run — an immediate second sync
// must produce an empty diff.
// Conflict model: per-playlist baselines (playlist_sync_state) make direction
// come from WHICH side changed since the last sync, not the clock. Only-one-
// side-changed syncs silently; both-changed (or a first-pairing name collision
// with divergent contents) is left untouched and surfaced as a
// DesktopSyncPlaylistConflict for the user to resolve. Timestamp LWW remains
// the fallback for pairs without a baseline yet.
import { openLibraryDb } from '@/db/database';
import { getAllTracks, setSetting } from '@/db/queries';
import { renamePlaylist } from '@/db/playlistQueries';
import {
adoptPlaylistSyncUid,
applySyncedFavoriteAdd,
applySyncedFavoriteRemove,
applySyncedPlaylistDelete,
clonePlaylistAsLocalCopy,
deletePlaylistSyncBaseline,
ensurePlaylistSyncUids,
getLocalSyncState,
getPlaylistSyncBaselines,
getSyncPlaylistEntries,
removeFavoriteTombstone,
removePlaylistTombstone,
replaceSyncedPlaylist,
resolvePendingFavorites,
upsertPendingFavorite,
upsertPlaylistSyncBaseline,
type LocalSyncPlaylist,
} from '@/db/desktopSyncQueries';
import { buildImportIndex, matchSyncEntry } from '@/library/playlistFiles';
import { normalizeSyncKeyPart } from '@/shared/sync/identity';
import { syncPlaylistToSnapshot } from '@/shared/sync/conflictPreview';
import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
import {
DESKTOP_SYNC_FORMAT,
DESKTOP_SYNC_MIN_PROTOCOL_VERSION,
type DesktopSyncApplyPayload,
type DesktopSyncConflictResolution,
type DesktopSyncPendingResolution,
type DesktopSyncPlaylistConflict,
type DesktopSyncSummary,
type SyncFavorite,
type SyncPlaylist,
type SyncPlaylistEntry,
} from '@/types/desktopSync';
import { mergePlaylistEntries, playlistEntriesEqual } from './desktopSyncPlaylistMerge';
import {
fetchDesktopRemoteIdentity,
fetchDesktopSyncState,
postDesktopSyncApply,
postDesktopSyncConflicts,
} from './desktopRemoteClient';
import {
getDesktopRemoteConnection,
getDesktopRemoteToken,
setDesktopRemoteConnection,
} from './desktopRemoteCredentials';
const CLOCK_SKEW_WARN_MS = 5 * 60_000;
/** The paired desktop runs a protocol without /v1/sync/* — needs an update. */
export class DesktopSyncUnsupportedError extends Error {
constructor() {
super('The desktop app needs an update before it can sync favorites and playlists.');
}
}
export function desktopSyncSettingKey(connection: Pick<DesktopRemoteConnection, 'id' | 'endpointUuid'>): string {
return `desktop_sync_last_${connection.endpointUuid ?? connection.id}`;
}
function newestFavorite(a: SyncFavorite | null, b: SyncFavorite | null): SyncFavorite | null {
if (!a) return b;
if (!b) return a;
return b.addedAt > a.addedAt ? b : a;
}
function maxTimestamp(a: number | null, b: number | null): number | null {
if (a === null) return b;
if (b === null) return a;
return Math.max(a, b);
}
function dynamicRulesEqual(a: string | null, b: string | null): boolean {
if (!a || !b) return a === b;
try {
return (
JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(a))) ===
JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(b)))
);
} catch {
return false;
}
}
function sanitizePendingResolutions(raw: unknown): DesktopSyncPendingResolution[] {
if (!Array.isArray(raw)) return [];
const resolutions: DesktopSyncPendingResolution[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const candidate = item as Record<string, unknown>;
const syncUid = typeof candidate.syncUid === 'string' ? candidate.syncUid : '';
const resolution = candidate.resolution;
if (!syncUid) continue;
if (resolution !== 'desktop' && resolution !== 'phone' && resolution !== 'both' && resolution !== 'merge') {
continue;
}
const decidedAt = Number(candidate.decidedAt);
resolutions.push({
syncUid,
resolution,
decidedAt: Number.isFinite(decidedAt) && decidedAt > 0 ? decidedAt : 0,
});
}
return resolutions;
}
/**
* One sync run + desktop-side signals: applies any conflict resolutions the
* desktop user chose (re-running once to settle them), then reports the
* remaining conflicts back so the desktop can display them.
*/
export async function runDesktopSync(): Promise<DesktopSyncSummary> {
const first = await runDesktopSyncOnce();
let summary = first.summary;
const consumedResolutions: string[] = [];
let appliedAny = false;
for (const pending of first.pendingResolutions) {
const conflict = summary.conflicts.find((entry) => entry.syncUid === pending.syncUid);
if (!conflict) {
// Already resolved on the phone (or gone) — acknowledge so it clears.
consumedResolutions.push(pending.syncUid);
continue;
}
try {
await applyDesktopSyncConflictResolution(conflict, pending.resolution);
appliedAny = true;
} catch (error) {
console.warn('Desktop-chosen sync resolution failed:', error);
}
// Consume either way — a bad choice (e.g. merge on a dynamic playlist)
// re-surfaces as a conflict for the desktop to re-decide, not a loop.
consumedResolutions.push(pending.syncUid);
}
if (appliedAny) {
summary = (await runDesktopSyncOnce()).summary;
}
// Report remaining conflicts (best-effort — older desktops 404 here).
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
if (connection && token) {
try {
await postDesktopSyncConflicts(connection.baseUrl, token, {
syncFormat: DESKTOP_SYNC_FORMAT,
conflicts: summary.conflicts.map((conflict) => ({
kind: conflict.kind,
syncUid: conflict.syncUid,
name: conflict.name,
playlistKind: conflict.playlistKind,
phoneName: conflict.localName,
desktopName: conflict.remoteName,
phoneUpdatedAt: conflict.localUpdatedAt,
desktopUpdatedAt: conflict.remoteUpdatedAt,
phoneTrackCount: conflict.localTrackCount,
desktopTrackCount: conflict.remoteTrackCount,
phoneSnapshot: syncPlaylistToSnapshot(conflict.local),
desktopSnapshot: syncPlaylistToSnapshot(conflict.remote),
})),
consumedResolutions,
});
} catch {
// The desktop just misses the conflict mirror; sync itself succeeded.
}
}
return summary;
}
async function runDesktopSyncOnce(): Promise<{
summary: DesktopSyncSummary;
pendingResolutions: DesktopSyncPendingResolution[];
}> {
const startedAt = Date.now();
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
if (!connection || !token) {
throw new Error('No paired desktop.');
}
// The stored protocolVersion predates any desktop upgrade — re-check live and
// persist the refreshed value before gating.
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
if (!identity) {
throw new Error('Desktop is unreachable.');
}
if (identity.protocolVersion !== connection.protocolVersion) {
await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion });
}
if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) {
throw new DesktopSyncUnsupportedError();
}
const db = await openLibraryDb();
await ensurePlaylistSyncUids(db);
const index = buildImportIndex(await getAllTracks(db));
await resolvePendingFavorites(db, index);
const local = await getLocalSyncState(db);
const remote = await fetchDesktopSyncState(connection.baseUrl, token);
if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) {
throw new DesktopSyncUnsupportedError();
}
if (Math.abs(remote.now - Date.now()) > CLOCK_SKEW_WARN_MS) {
console.warn(
`Desktop sync: clock skew of ${Math.round(Math.abs(remote.now - Date.now()) / 1000)}s detected; ` +
'last-writer-wins conflict resolution may pick the wrong side.'
);
}
const payload: DesktopSyncApplyPayload = {
syncFormat: DESKTOP_SYNC_FORMAT,
favoriteAdds: [],
favoriteRemoves: [],
playlistUpserts: [],
playlistDeletes: [],
};
const summary: DesktopSyncSummary = {
favoritesAdded: 0,
favoritesRemoved: 0,
favoritesPending: 0,
playlistsCreated: 0,
playlistsReplaced: 0,
playlistsDeleted: 0,
playlistsSkipped: 0,
entriesFallback: 0,
pushedToDesktop: false,
conflicts: [],
startedAt,
finishedAt: startedAt,
};
const baselines = await getPlaylistSyncBaselines(db);
// Baselines are recorded only for playlists that END this run in sync;
// push-dependent ones wait for the desktop's per-playlist apply result.
const baselinePlans: { uid: string; localUpdatedAt: number; remoteUpdatedAt: number; afterPush: boolean }[] = [];
const remoteFavByKey = new Map(remote.favorites.map((favorite) => [favorite.key, favorite]));
const remoteFavTombByKey = new Map(remote.favoriteTombstones.map((tomb) => [tomb.key, tomb.deletedAt]));
const remoteByUid = new Map(remote.playlists.map((playlist) => [playlist.syncUid, playlist]));
const remoteTombByUid = new Map(remote.playlistTombstones.map((tomb) => [tomb.syncUid, tomb.deletedAt]));
await db.transaction(async (tx) => {
// ── Favorites ────────────────────────────────────────────────────────────
const favoriteKeys = new Set<string>([
...local.favorites.keys(),
...local.favoriteTombstones.keys(),
...remoteFavByKey.keys(),
...remoteFavTombByKey.keys(),
]);
for (const key of favoriteKeys) {
const localFav = local.favorites.get(key) ?? null;
const remoteFav = remoteFavByKey.get(key) ?? null;
const localTombAt = local.favoriteTombstones.get(key) ?? null;
const bestAdd = newestFavorite(localFav, remoteFav);
const bestDelAt = maxTimestamp(localTombAt, remoteFavTombByKey.get(key) ?? null);
// Tie between an add and a delete keeps the favorite (deterministic on
// both sides).
const present = bestAdd !== null && (bestDelAt === null || bestAdd.addedAt >= bestDelAt);
if (present) {
if (localTombAt !== null) {
await removeFavoriteTombstone(tx, key);
}
if (!localFav || localFav.pending) {
const match = matchSyncEntry(
{ title: bestAdd.title, artist: bestAdd.artist, album: bestAdd.album },
index
);
if (match.kind === 'matched') {
await applySyncedFavoriteAdd(tx, match.track.path, key, bestAdd.addedAt);
summary.favoritesAdded += 1;
} else if (!localFav || localFav.addedAt < bestAdd.addedAt) {
await upsertPendingFavorite(tx, {
key,
title: bestAdd.title,
artist: bestAdd.artist,
album: bestAdd.album,
addedAt: bestAdd.addedAt,
});
if (!localFav) summary.favoritesPending += 1;
}
}
if (!remoteFav) {
payload.favoriteAdds.push({
key,
title: bestAdd.title,
artist: bestAdd.artist,
album: bestAdd.album,
addedAt: bestAdd.addedAt,
});
}
} else if (bestDelAt !== null) {
if (localFav) {
await applySyncedFavoriteRemove(tx, localFav.trackPaths, key, bestDelAt);
if (!localFav.pending) summary.favoritesRemoved += 1;
} else if (localTombAt === null || localTombAt < bestDelAt) {
// Record the peer's tombstone locally so the merge stays
// deterministic even if the desktop ever loses its copy.
await applySyncedFavoriteRemove(tx, [], key, bestDelAt);
}
if (remoteFav) {
payload.favoriteRemoves.push({ key, deletedAt: bestDelAt });
}
}
}
// ── Playlists ────────────────────────────────────────────────────────────
const localByUid = new Map(local.playlists.map((playlist) => [playlist.syncUid, playlist]));
const skippedConflictUids = new Set<string>();
const localContentsFor = async (row: LocalSyncPlaylist): Promise<SyncPlaylistEntry[]> =>
row.kind === 'normal' ? getSyncPlaylistEntries(tx, row.id) : [];
const contentsMatch = async (
localRow: LocalSyncPlaylist,
remoteRow: SyncPlaylist,
localEntries: SyncPlaylistEntry[]
): Promise<boolean> => {
if (localRow.kind !== remoteRow.kind) return false;
if (localRow.kind === 'dynamic') {
return dynamicRulesEqual(localRow.dynamicRules, remoteRow.dynamicRules);
}
return playlistEntriesEqual(localEntries, remoteRow.entries ?? []);
};
const buildConflict = (
kind: DesktopSyncPlaylistConflict['kind'],
localRow: LocalSyncPlaylist,
remoteRow: SyncPlaylist,
localEntries: SyncPlaylistEntry[]
): DesktopSyncPlaylistConflict => {
const localSnapshot: SyncPlaylist = {
syncUid: localRow.syncUid,
name: localRow.name,
kind: localRow.kind,
dynamicRules: localRow.kind === 'dynamic' ? localRow.dynamicRules : null,
createdAt: localRow.createdAt,
updatedAt: localRow.updatedAt,
entries: localRow.kind === 'normal' ? localEntries : null,
};
return {
kind,
syncUid: remoteRow.syncUid,
localPlaylistId: localRow.id,
localSyncUid: localRow.syncUid,
name: localRow.name,
playlistKind: localRow.kind === 'normal' && remoteRow.kind === 'normal' ? 'normal' : 'dynamic',
localName: localRow.name,
remoteName: remoteRow.name,
localUpdatedAt: localRow.updatedAt,
remoteUpdatedAt: remoteRow.updatedAt,
localTrackCount: localSnapshot.entries?.length ?? 0,
remoteTrackCount: remoteRow.entries?.length ?? 0,
local: localSnapshot,
remote: remoteRow,
};
};
// First sync of a playlist that exists on both sides under different uids:
// pair case-insensitively by name and adopt the DESKTOP uid (identity
// adoption is not an edit — updated_at stays put). Pairing only happens
// automatically when the contents already match; divergent same-named
// lists become a first-pairing conflict and BOTH copies are left alone.
for (const remotePlaylist of remote.playlists) {
if (localByUid.has(remotePlaylist.syncUid)) continue;
if (local.playlistTombstones.has(remotePlaylist.syncUid)) continue;
const nameKey = normalizeSyncKeyPart(remotePlaylist.name);
if (!nameKey) continue;
let paired: LocalSyncPlaylist | null = null;
for (const candidate of local.playlists) {
if (candidate.syncUid === remotePlaylist.syncUid) continue;
if (remoteByUid.has(candidate.syncUid) || remoteTombByUid.has(candidate.syncUid)) continue;
if (normalizeSyncKeyPart(candidate.name) !== nameKey) continue;
if (paired === null || candidate.id < paired.id) paired = candidate;
}
if (!paired) continue;
const pairedEntries = await localContentsFor(paired);
if (await contentsMatch(paired, remotePlaylist, pairedEntries)) {
await adoptPlaylistSyncUid(tx, paired.id, remotePlaylist.syncUid);
localByUid.delete(paired.syncUid);
paired.syncUid = remotePlaylist.syncUid;
localByUid.set(remotePlaylist.syncUid, paired);
} else {
summary.conflicts.push(
buildConflict('first-pairing', paired, remotePlaylist, pairedEntries)
);
skippedConflictUids.add(remotePlaylist.syncUid);
skippedConflictUids.add(paired.syncUid);
}
}
const playlistUids = new Set<string>([
...localByUid.keys(),
...remoteByUid.keys(),
...local.playlistTombstones.keys(),
...remoteTombByUid.keys(),
]);
for (const uid of playlistUids) {
if (skippedConflictUids.has(uid)) continue;
const localRow = localByUid.get(uid) ?? null;
const remoteRow = remoteByUid.get(uid) ?? null;
const localTombAt = local.playlistTombstones.get(uid) ?? null;
const bestTombAt = maxTimestamp(localTombAt, remoteTombByUid.get(uid) ?? null);
const bestRowAt = maxTimestamp(localRow?.updatedAt ?? null, remoteRow?.updatedAt ?? null);
// Deletion wins only when strictly newer than the newest edit.
if (bestTombAt !== null && (bestRowAt === null || bestTombAt > bestRowAt)) {
if (localRow) {
await applySyncedPlaylistDelete(tx, uid, bestTombAt);
summary.playlistsDeleted += 1;
} else if (localTombAt === null || localTombAt < bestTombAt) {
await applySyncedPlaylistDelete(tx, uid, bestTombAt);
}
await deletePlaylistSyncBaseline(tx, uid);
if (remoteRow) {
payload.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt });
}
continue;
}
const pushLocal = async (row: LocalSyncPlaylist) => {
if (localTombAt !== null) {
await removePlaylistTombstone(tx, uid);
}
payload.playlistUpserts.push({
syncUid: uid,
name: row.name,
kind: row.kind,
dynamicRules: row.dynamicRules,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
entries: row.kind === 'normal' ? await getSyncPlaylistEntries(tx, row.id) : null,
} satisfies SyncPlaylist);
baselinePlans.push({
uid,
localUpdatedAt: row.updatedAt,
remoteUpdatedAt: row.updatedAt,
afterPush: true,
});
};
const applyRemote = async (row: SyncPlaylist) => {
const result = await replaceSyncedPlaylist(tx, row, index);
if (result.status === 'created') summary.playlistsCreated += 1;
else if (result.status === 'replaced') summary.playlistsReplaced += 1;
else summary.playlistsSkipped += 1;
summary.entriesFallback += result.entriesFallback;
if (result.status !== 'skipped-incompatible') {
baselinePlans.push({
uid,
localUpdatedAt: row.updatedAt,
remoteUpdatedAt: row.updatedAt,
afterPush: false,
});
}
};
// With a baseline, sync direction comes from WHICH side changed since
// the last run — the clock only matters when both did (conflict).
const baseline = baselines.get(uid) ?? null;
if (baseline && localRow && remoteRow) {
const localChanged = localRow.updatedAt !== baseline.localUpdatedAt;
const remoteChanged = remoteRow.updatedAt !== baseline.remoteUpdatedAt;
if (localChanged && remoteChanged) {
const localEntries = await localContentsFor(localRow);
const trivial =
localRow.name.trim() === remoteRow.name.trim() &&
(await contentsMatch(localRow, remoteRow, localEntries));
if (trivial) {
// Both sides moved to the same result (e.g. identical edits) —
// just advance the baseline.
baselinePlans.push({
uid,
localUpdatedAt: localRow.updatedAt,
remoteUpdatedAt: remoteRow.updatedAt,
afterPush: false,
});
} else {
summary.conflicts.push(
buildConflict('concurrent-edit', localRow, remoteRow, localEntries)
);
}
continue;
}
if (remoteChanged) {
await applyRemote(remoteRow);
continue;
}
if (localChanged) {
await pushLocal(localRow);
continue;
}
continue; // Neither side moved.
}
// No baseline yet (first sync of this pair): timestamp last-writer-wins.
if (remoteRow && (localRow === null || localRow.updatedAt < remoteRow.updatedAt)) {
await applyRemote(remoteRow);
continue;
}
if (localRow && (remoteRow === null || remoteRow.updatedAt < localRow.updatedAt)) {
await pushLocal(localRow);
continue;
}
if (localRow && remoteRow) {
// Equal timestamps on both sides: in sync — record the first baseline.
baselinePlans.push({
uid,
localUpdatedAt: localRow.updatedAt,
remoteUpdatedAt: remoteRow.updatedAt,
afterPush: false,
});
}
}
});
// Baselines that don't depend on the push are valid as soon as the local
// transaction committed.
for (const plan of baselinePlans) {
if (plan.afterPush) continue;
const existing = baselines.get(plan.uid);
if (
existing &&
existing.localUpdatedAt === plan.localUpdatedAt &&
existing.remoteUpdatedAt === plan.remoteUpdatedAt
) {
continue;
}
await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt);
}
const hasDiff =
payload.favoriteAdds.length > 0 ||
payload.favoriteRemoves.length > 0 ||
payload.playlistUpserts.length > 0 ||
payload.playlistDeletes.length > 0;
if (hasDiff) {
const result = await postDesktopSyncApply(connection.baseUrl, token, payload);
summary.pushedToDesktop = true;
summary.favoritesAdded += result.favorites.added;
summary.favoritesPending += result.favorites.pending;
summary.favoritesRemoved += result.favorites.removed;
const pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status]));
for (const playlistResult of result.playlists) {
if (playlistResult.status === 'created') summary.playlistsCreated += 1;
else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1;
else if (playlistResult.status === 'deleted') summary.playlistsDeleted += 1;
else summary.playlistsSkipped += 1;
summary.entriesFallback += playlistResult.entriesFallback;
}
// Push-dependent baselines only count once the desktop confirmed the
// upsert; a failed/skipped push re-syncs naturally next run.
for (const plan of baselinePlans) {
if (!plan.afterPush) continue;
const status = pushStatusByUid.get(plan.uid);
if (status !== 'created' && status !== 'replaced') continue;
await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt);
}
}
await setSetting(db, desktopSyncSettingKey(connection), String(Date.now()));
await usePlaylistStore.getState().refresh();
summary.finishedAt = Date.now();
return { summary, pendingResolutions: sanitizePendingResolutions(remote.pendingResolutions) };
}
/**
* Applies the user's choice for one sync conflict LOCALLY (no network):
* either adjusting the sync baseline so the next run pulls/pushes the chosen
* side, or restructuring the local copies for keep-both/merge. The caller
* should run a sync afterwards to settle both devices; if the desktop moved
* again in the meantime the conflict legitimately re-surfaces.
*/
export async function applyDesktopSyncConflictResolution(
conflict: DesktopSyncPlaylistConflict,
resolution: DesktopSyncConflictResolution
): Promise<void> {
const db = await openLibraryDb();
const currentRow = await db.get<{ updated_at: number; name: string }>(
'SELECT updated_at, name FROM playlists WHERE id = ?',
[conflict.localPlaylistId]
);
if (!currentRow) {
// The local copy vanished since detection — nothing to choose between;
// the next sync settles whatever remains.
return;
}
switch (resolution) {
case 'desktop': {
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
// Local reads as unchanged, remote as changed → next run pulls desktop.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0);
break;
}
case 'phone': {
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
// Remote reads as unchanged (as of detection), local as changed → next
// run pushes the phone copy.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt);
break;
}
case 'both': {
const copyName = `${currentRow.name} (Phone)`;
if (conflict.kind === 'first-pairing') {
// Rename the local copy out of the collision; both lists then sync as
// independent playlists.
await renamePlaylist(db, conflict.localPlaylistId, copyName);
} else {
// Duplicate the local version under a fresh identity, then let the
// shared uid take the desktop version.
await clonePlaylistAsLocalCopy(db, conflict.localPlaylistId, copyName);
await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0);
}
break;
}
case 'merge': {
if (conflict.playlistKind !== 'normal') {
throw new Error('Dynamic playlists cannot be merged — keep one side instead.');
}
const localEntries = await getSyncPlaylistEntries(db, conflict.localPlaylistId);
const remoteEntries = conflict.remote.entries ?? [];
const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt;
const merged = mergePlaylistEntries(
localIsNewer ? localEntries : remoteEntries,
localIsNewer ? remoteEntries : localEntries
);
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
const index = buildImportIndex(await getAllTracks(db));
await replaceSyncedPlaylist(
db,
{
syncUid: conflict.syncUid,
name: localIsNewer ? currentRow.name : conflict.remote.name,
kind: 'normal',
dynamicRules: null,
createdAt: conflict.remote.createdAt,
updatedAt: Date.now(),
entries: merged,
},
index
);
// The merged list is a fresh local edit → next run pushes it.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt);
break;
}
}
await usePlaylistStore.getState().refresh();
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
mergePlaylistEntries,
playlistEntriesEqual,
} from './desktopSyncPlaylistMerge.ts';
import type { SyncPlaylistEntry } from '../types/desktopSync.ts';
function entry(title: string, position: number, overrides: Partial<SyncPlaylistEntry> = {}): SyncPlaylistEntry {
return {
title,
artist: 'Artist',
album: 'Album',
durationSeconds: 200,
position,
addedAt: 1_000 + position,
sourcePath: null,
...overrides,
};
}
test('equal contents in the same order compare equal despite case/whitespace', () => {
const a = [entry('One', 0), entry('Two', 1)];
const b = [entry(' one ', 0), entry('TWO', 1)];
assert.equal(playlistEntriesEqual(a, b), true);
});
test('same songs in a different order are NOT equal (order is content)', () => {
const a = [entry('One', 0), entry('Two', 1)];
const b = [entry('Two', 0), entry('One', 1)];
assert.equal(playlistEntriesEqual(a, b), false);
});
test('different lengths are not equal', () => {
assert.equal(playlistEntriesEqual([entry('One', 0)], []), false);
});
test('merge keeps the newer order first and appends the older side extras', () => {
const newer = [entry('A', 0), entry('B', 1), entry('C', 2)];
const older = [entry('B', 0), entry('X', 1), entry('A', 2), entry('Y', 3)];
const merged = mergePlaylistEntries(newer, older);
assert.deepEqual(
merged.map((e) => e.title),
['A', 'B', 'C', 'X', 'Y']
);
assert.deepEqual(
merged.map((e) => e.position),
[0, 1, 2, 3, 4]
);
});
test('merged entries keep their origin metadata', () => {
const newer = [entry('A', 0, { addedAt: 111 })];
const older = [entry('Z', 0, { addedAt: 999, sourcePath: 'D:/z.flac' })];
const merged = mergePlaylistEntries(newer, older);
assert.equal(merged[1].addedAt, 999);
assert.equal(merged[1].sourcePath, 'D:/z.flac');
});
test('merge respects stored positions, not array order', () => {
const newer = [entry('B', 1), entry('A', 0)];
const merged = mergePlaylistEntries(newer, []);
assert.deepEqual(
merged.map((e) => e.title),
['A', 'B']
);
});
+50
View File
@@ -0,0 +1,50 @@
// Pure playlist content comparison + union merge for desktop sync conflicts
// (node-testable, no RN imports). Entries are compared by the shared metadata
// identity key — the same identity favorites sync on.
// Runtime imports are relative with explicit .ts so this runs under
// `node --test`; type-only '@/' imports are erased by strip-types.
import type { SyncPlaylistEntry } from '@/types/desktopSync';
import { buildTrackSyncKey } from '../shared/sync/identity.ts';
export function playlistEntryKey(
entry: Pick<SyncPlaylistEntry, 'title' | 'artist' | 'album'>
): string {
return buildTrackSyncKey(entry.title, entry.artist, entry.album);
}
/** Same songs in the same order (by metadata identity). */
export function playlistEntriesEqual(a: SyncPlaylistEntry[], b: SyncPlaylistEntry[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (playlistEntryKey(a[i]) !== playlistEntryKey(b[i])) return false;
}
return true;
}
/**
* Union merge: the newer side's entries keep their order, then the older
* side's entries whose identity isn't already present are appended (in their
* own order). Positions are renumbered; each entry keeps its origin metadata
* (addedAt, sourcePath, duration).
*/
export function mergePlaylistEntries(
newer: SyncPlaylistEntry[],
older: SyncPlaylistEntry[]
): SyncPlaylistEntry[] {
const merged: SyncPlaylistEntry[] = [];
const seenKeys = new Set<string>();
for (const entry of [...newer].sort((a, b) => a.position - b.position)) {
const key = playlistEntryKey(entry);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
merged.push({ ...entry, position: merged.length });
}
for (const entry of [...older].sort((a, b) => a.position - b.position)) {
const key = playlistEntryKey(entry);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
merged.push({ ...entry, position: merged.length });
}
return merged;
}
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSyncConflictResolutionPreview,
buildSyncPlaylistEntryDiff,
mergePlaylistEntriesForPreview,
syncPlaylistToSnapshot,
} from './conflictPreview.ts';
import type { SyncPlaylist, SyncPlaylistEntry } from '../../types/desktopSync.ts';
function entry(title: string, position: number, overrides: Partial<SyncPlaylistEntry> = {}): SyncPlaylistEntry {
return {
title,
artist: 'Artist',
album: 'Album',
durationSeconds: 180,
position,
addedAt: 1_000 + position,
sourcePath: null,
...overrides,
};
}
function playlist(overrides: Partial<SyncPlaylist> = {}): SyncPlaylist {
return {
syncUid: 'uid',
name: 'Playlist',
kind: 'normal',
dynamicRules: null,
createdAt: 1,
updatedAt: 2,
entries: [],
...overrides,
};
}
test('entry diff reports moved and side-only tracks', () => {
const diff = buildSyncPlaylistEntryDiff(
[entry('A', 0), entry('B', 1), entry('Desktop only', 2)],
[entry('B', 0), entry('A', 1), entry('Phone only', 2)]
);
assert.equal(diff.movedCount, 2);
assert.equal(diff.desktopOnlyCount, 1);
assert.equal(diff.phoneOnlyCount, 1);
assert.deepEqual(
diff.rows.map((row) => row.status),
['moved', 'moved', 'desktop-only', 'phone-only']
);
});
test('entry diff treats duplicate occurrences independently', () => {
const diff = buildSyncPlaylistEntryDiff(
[entry('A', 0), entry('A', 1)],
[entry('A', 0)]
);
assert.equal(diff.sameCount, 1);
assert.equal(diff.desktopOnlyCount, 1);
});
test('merge preview keeps newer order and appends missing older tracks', () => {
const merged = mergePlaylistEntriesForPreview(
[entry('B', 1), entry('A', 0)],
[entry('A', 0), entry('C', 1)]
);
assert.deepEqual(
merged.map((item) => item.title),
['A', 'B', 'C']
);
assert.deepEqual(
merged.map((item) => item.position),
[0, 1, 2]
);
});
test('resolution preview uses newer side for merge result name', () => {
const desktop = syncPlaylistToSnapshot(playlist({
name: 'Desktop Mix',
updatedAt: 20,
entries: [entry('Desktop Track', 0)],
}));
const phone = syncPlaylistToSnapshot(playlist({
name: 'Phone Mix',
updatedAt: 10,
entries: [entry('Phone Track', 0)],
}));
const preview = buildSyncConflictResolutionPreview('merge', desktop, phone);
assert.equal(preview.resultName, 'Desktop Mix');
assert.equal(preview.resultTrackCount, 2);
});
+194
View File
@@ -0,0 +1,194 @@
import type {
DesktopSyncConflictResolution,
SyncPlaylist,
SyncPlaylistEntry,
SyncPlaylistSnapshot,
} from '../../types/desktopSync';
import { buildTrackSyncKey } from './identity.ts';
export type SyncPlaylistEntryDiffStatus = 'same' | 'moved' | 'desktop-only' | 'phone-only';
export interface SyncPlaylistEntryDiff {
key: string;
title: string;
artist: string;
album: string;
desktopIndex: number | null;
phoneIndex: number | null;
status: SyncPlaylistEntryDiffStatus;
}
export interface SyncPlaylistEntryDiffSummary {
rows: SyncPlaylistEntryDiff[];
sameCount: number;
movedCount: number;
desktopOnlyCount: number;
phoneOnlyCount: number;
}
export interface SyncConflictResolutionPreview {
resolution: DesktopSyncConflictResolution;
title: string;
detail: string;
resultName: string;
resultTrackCount: number | null;
mergedEntries: SyncPlaylistEntry[] | null;
}
export function syncPlaylistToSnapshot(playlist: Pick<SyncPlaylist, 'name' | 'kind' | 'dynamicRules' | 'updatedAt' | 'entries'>): SyncPlaylistSnapshot {
const entries = playlist.kind === 'normal' ? playlist.entries ?? [] : null;
return {
name: playlist.name,
kind: playlist.kind,
dynamicRules: playlist.kind === 'dynamic' ? playlist.dynamicRules : null,
updatedAt: playlist.updatedAt,
trackCount: entries?.length ?? 0,
entries,
};
}
function orderedEntries(entries: readonly SyncPlaylistEntry[] | null | undefined): SyncPlaylistEntry[] {
return [...(entries ?? [])].sort((left, right) => left.position - right.position);
}
function entryIdentity(entry: Pick<SyncPlaylistEntry, 'title' | 'artist' | 'album'>): string {
return buildTrackSyncKey(entry.title, entry.artist, entry.album);
}
function occurrenceRows(entries: readonly SyncPlaylistEntry[] | null | undefined) {
const counts = new Map<string, number>();
return orderedEntries(entries).map((entry, index) => {
const identity = entryIdentity(entry);
const occurrence = counts.get(identity) ?? 0;
counts.set(identity, occurrence + 1);
return {
key: `${identity}\u0000${occurrence}`,
index,
entry,
};
});
}
export function buildSyncPlaylistEntryDiff(
desktopEntries: readonly SyncPlaylistEntry[] | null | undefined,
phoneEntries: readonly SyncPlaylistEntry[] | null | undefined
): SyncPlaylistEntryDiffSummary {
const desktop = occurrenceRows(desktopEntries);
const phone = occurrenceRows(phoneEntries);
const desktopByKey = new Map(desktop.map((row) => [row.key, row]));
const phoneByKey = new Map(phone.map((row) => [row.key, row]));
const keys = new Set<string>([...desktopByKey.keys(), ...phoneByKey.keys()]);
const rows: SyncPlaylistEntryDiff[] = [];
for (const key of keys) {
const desktopRow = desktopByKey.get(key) ?? null;
const phoneRow = phoneByKey.get(key) ?? null;
const source = desktopRow?.entry ?? phoneRow?.entry;
if (!source) continue;
const desktopIndex = desktopRow ? desktopRow.index : null;
const phoneIndex = phoneRow ? phoneRow.index : null;
let status: SyncPlaylistEntryDiffStatus = 'same';
if (!desktopRow) status = 'phone-only';
else if (!phoneRow) status = 'desktop-only';
else if (desktopIndex !== phoneIndex) status = 'moved';
rows.push({
key,
title: source.title,
artist: source.artist,
album: source.album,
desktopIndex,
phoneIndex,
status,
});
}
rows.sort((left, right) => {
const leftIndex = Math.min(left.desktopIndex ?? Number.MAX_SAFE_INTEGER, left.phoneIndex ?? Number.MAX_SAFE_INTEGER);
const rightIndex = Math.min(right.desktopIndex ?? Number.MAX_SAFE_INTEGER, right.phoneIndex ?? Number.MAX_SAFE_INTEGER);
return leftIndex - rightIndex;
});
return {
rows,
sameCount: rows.filter((row) => row.status === 'same').length,
movedCount: rows.filter((row) => row.status === 'moved').length,
desktopOnlyCount: rows.filter((row) => row.status === 'desktop-only').length,
phoneOnlyCount: rows.filter((row) => row.status === 'phone-only').length,
};
}
export function mergePlaylistEntriesForPreview(
newer: readonly SyncPlaylistEntry[] | null | undefined,
older: readonly SyncPlaylistEntry[] | null | undefined
): SyncPlaylistEntry[] {
const merged: SyncPlaylistEntry[] = [];
const seenKeys = new Set<string>();
for (const entry of [...orderedEntries(newer), ...orderedEntries(older)]) {
const key = entryIdentity(entry);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
merged.push({ ...entry, position: merged.length });
}
return merged;
}
export function buildSyncConflictResolutionPreview(
resolution: DesktopSyncConflictResolution,
desktop: SyncPlaylistSnapshot,
phone: SyncPlaylistSnapshot
): SyncConflictResolutionPreview {
switch (resolution) {
case 'desktop':
return {
resolution,
title: 'Keep desktop',
detail: 'The desktop version will replace the phone copy on the next sync.',
resultName: desktop.name,
resultTrackCount: desktop.kind === 'normal' ? desktop.trackCount : null,
mergedEntries: null,
};
case 'phone':
return {
resolution,
title: 'Keep phone',
detail: 'The phone version will replace the desktop copy on the next sync.',
resultName: phone.name,
resultTrackCount: phone.kind === 'normal' ? phone.trackCount : null,
mergedEntries: null,
};
case 'both':
return {
resolution,
title: 'Keep both',
detail: 'Both versions will remain as separate playlists after the next sync.',
resultName: phone.name,
resultTrackCount: phone.kind === 'normal' ? phone.trackCount + desktop.trackCount : null,
mergedEntries: null,
};
case 'merge': {
if (desktop.kind !== 'normal' || phone.kind !== 'normal') {
return {
resolution,
title: 'Merge unavailable',
detail: 'Dynamic playlists cannot be merged. Keep one side or keep both instead.',
resultName: phone.name,
resultTrackCount: null,
mergedEntries: null,
};
}
const phoneIsNewer = phone.updatedAt >= desktop.updatedAt;
const mergedEntries = mergePlaylistEntriesForPreview(
phoneIsNewer ? phone.entries : desktop.entries,
phoneIsNewer ? desktop.entries : phone.entries
);
return {
resolution,
title: 'Merge',
detail: 'The newer order stays first, then missing songs from the other side are appended.',
resultName: phoneIsNewer ? phone.name : desktop.name,
resultTrackCount: mergedEntries.length,
mergedEntries,
};
}
}
}
+21
View File
@@ -0,0 +1,21 @@
// Cross-device track identity for desktop<->mobile LAN sync. Track paths never
// match across devices (desktop filesystem vs Android SAF content:// URIs), so
// favorites and playlist entries travel as normalized metadata keys and each
// side resolves them against its own library. Normalization mirrors library.ts
// normalizeKey (whitespace-collapse + locale lowercase).
// This file is ported verbatim to astra-mobile/src/shared/sync/identity.ts —
// keep the two copies identical.
export const TRACK_SYNC_KEY_SEPARATOR = '\u001f'
export function normalizeSyncKeyPart(value: string): string {
return value.replace(/\s+/g, ' ').trim().toLocaleLowerCase()
}
export function buildTrackSyncKey(title: string, artist: string, album: string): string {
return [
normalizeSyncKeyPart(title),
normalizeSyncKeyPart(artist),
normalizeSyncKeyPart(album)
].join(TRACK_SYNC_KEY_SEPARATOR)
}
+60 -1
View File
@@ -11,10 +11,12 @@ import {
fetchDesktopRemoteIdentity,
fetchDesktopRemoteNowPlaying,
fetchDesktopRemotePairingStatus,
fetchDesktopRemoteQueue,
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
requestDesktopRemotePinPairing,
sendDesktopRemoteControl,
sendDesktopRemotePlayQueueItem,
startDesktopRemoteEventStream,
} from '@/services/desktopRemoteClient';
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
@@ -25,12 +27,16 @@ import {
setDesktopRemoteConnection,
setDesktopRemoteToken,
} from '@/services/desktopRemoteCredentials';
import { openLibraryDb } from '@/db/database';
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import type {
DesktopRemoteConnection,
DesktopRemoteControlCommand,
DesktopRemoteDiscoveredDesktop,
DesktopRemoteIdentity,
DesktopRemoteNowPlayingSnapshot,
DesktopRemoteQueueSnapshot,
} from '@/types/desktopRemote';
const PAIR_POLL_INTERVAL_MS = 1500;
@@ -66,6 +72,8 @@ interface DesktopRemoteStore {
connection: DesktopRemoteConnection | null;
token: string | null;
snapshot: DesktopRemoteNowPlayingSnapshot | null;
/** Desktop queue (current + upcoming); null on protocol-1 desktops. */
queue: DesktopRemoteQueueSnapshot | null;
discovered: DesktopRemoteDiscoveredDesktop[];
discoveryAvailable: boolean;
discoveryRunning: boolean;
@@ -86,6 +94,8 @@ interface DesktopRemoteStore {
disconnect: () => void;
forget: () => Promise<void>;
sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise<void>;
refreshQueue: () => Promise<void>;
playQueueItem: (queueId: string) => Promise<void>;
}
let pairingPollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -210,11 +220,15 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
if (!connection || !token || connectionState === 'connecting') return;
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
(snapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
snapshot: mergeSnapshotArtwork(state.snapshot, snapshot),
connectionState: 'connected',
errorMessage: '',
}));
if (!wasConnected) {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
refreshInlineArtwork();
},
(error) => {
@@ -434,6 +448,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
connection: null,
token: null,
snapshot: null,
queue: null,
discovered: [],
discoveryAvailable: desktopRemoteDiscoveryAvailable,
discoveryRunning: false,
@@ -536,16 +551,27 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
message: '',
errorMessage: '',
});
useDesktopSyncStore.getState().maybeAutoSync('connected');
stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
onSnapshot: (nextSnapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot),
connectionState: 'connected',
message: '',
errorMessage: '',
}));
if (!wasConnected) {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
refreshInlineArtwork();
},
onQueue: (queue) => {
set({ queue });
},
onSyncRequest: () => {
useDesktopSyncStore.getState().handleSyncRequest();
},
onUnauthorized: () => {
void get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -556,6 +582,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
},
});
scheduleSnapshotPoll();
void get().refreshQueue();
return true;
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
@@ -580,18 +607,23 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
disconnect: () => {
stopRealtime();
clearPairingPoll();
set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, pinPairing: null });
set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, queue: null, pinPairing: null });
},
forget: async () => {
stopRealtime();
clearPairingPoll();
await clearDesktopRemotePairing();
// Sync baselines are meaningless against a different desktop.
void openLibraryDb()
.then((db) => clearPlaylistSyncBaselines(db))
.catch(() => {});
set({
connectionState: 'unpaired',
connection: null,
token: null,
snapshot: null,
queue: null,
pairing: null,
pinPairing: null,
message: '',
@@ -618,5 +650,32 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
set({ errorMessage: errorMessage(error) });
}
},
refreshQueue: async () => {
const { connection, token } = get();
if (!connection || !token) return;
try {
const queue = await fetchDesktopRemoteQueue(connection.baseUrl, token);
set({ queue });
} catch {
// Protocol-1 desktops 404 here; the queue UI simply stays hidden.
}
},
playQueueItem: async (queueId) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
await sendDesktopRemotePlayQueueItem(connection.baseUrl, token, queueId);
set({ errorMessage: '' });
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
return;
}
set({ errorMessage: errorMessage(error) });
}
},
};
});
+199
View File
@@ -0,0 +1,199 @@
// Desktop LAN sync status + triggers. The engine itself lives in
// src/services/desktopSync.ts; this store serializes runs (one at a time),
// debounces the automatic triggers, and exposes last-synced state for the UI.
// Module-scope timers (not component state) per the React Compiler rules.
import { AppState } from 'react-native';
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import {
DesktopSyncUnsupportedError,
applyDesktopSyncConflictResolution,
desktopSyncSettingKey,
runDesktopSync,
} from '@/services/desktopSync';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
DesktopSyncSummary,
} from '@/types/desktopSync';
const AUTO_SYNC_DEBOUNCE_MS = 5_000;
const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000;
const AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto';
export type DesktopSyncStatus = 'idle' | 'syncing' | 'error';
export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground';
interface DesktopSyncStore {
status: DesktopSyncStatus;
/** Wall-clock ms of the last successful sync with the paired desktop. */
lastSyncAt: number | null;
lastSummary: DesktopSyncSummary | null;
/** Playlists the last run refused to resolve automatically. */
conflicts: DesktopSyncPlaylistConflict[];
/** True while a NEW conflict awaits its once-per-session popup
* (SyncConflictPrompt); set by syncNow, cleared on dismiss/resolve. */
conflictPromptVisible: boolean;
errorMessage: string;
/** False once the paired desktop reported a pre-sync protocol version. */
supported: boolean;
/** Automatic syncing (foreground/discovery). Manual + desktop-requested
* syncs run regardless. */
autoSyncEnabled: boolean;
hydrate: () => Promise<void>;
syncNow: () => Promise<void>;
dismissConflictPrompt: () => void;
setAutoSyncEnabled: (enabled: boolean) => Promise<void>;
resolveConflict: (
conflict: DesktopSyncPlaylistConflict,
resolution: DesktopSyncConflictResolution
) => Promise<void>;
maybeAutoSync: (reason: DesktopSyncAutoReason) => void;
/** Desktop clicked Sync Now (or resolved a conflict there) — sync promptly,
* bypassing the auto-sync interval limits. */
handleSyncRequest: () => void;
}
let autoSyncDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// One popup per conflict per app session — dismissing must not re-nag on the
// next sync run re-detecting the same conflicts.
const promptedConflictUids = new Set<string>();
export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
status: 'idle',
lastSyncAt: null,
lastSummary: null,
conflicts: [],
conflictPromptVisible: false,
errorMessage: '',
supported: true,
autoSyncEnabled: true,
hydrate: async () => {
try {
const db = await openLibraryDb();
const autoSetting = await getSetting(db, AUTO_SYNC_SETTING_KEY);
if (autoSetting !== null) {
set({ autoSyncEnabled: autoSetting !== '0' });
}
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const stored = await getSetting(db, desktopSyncSettingKey(connection));
const lastSyncAt = stored ? Number(stored) : NaN;
if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
set({ lastSyncAt });
}
} catch {
// Hydration is best-effort; the first sync will set lastSyncAt.
}
},
setAutoSyncEnabled: async (enabled) => {
set({ autoSyncEnabled: enabled });
try {
const db = await openLibraryDb();
await setSetting(db, AUTO_SYNC_SETTING_KEY, enabled ? '1' : '0');
} catch {
// The in-memory value still applies for this session.
}
},
syncNow: async () => {
if (get().status === 'syncing') return;
set({ status: 'syncing', errorMessage: '' });
try {
const summary = await runDesktopSync();
let conflictPromptVisible = get().conflictPromptVisible;
if (summary.conflicts.length === 0) {
promptedConflictUids.clear();
conflictPromptVisible = false;
} else if (summary.conflicts.some((conflict) => !promptedConflictUids.has(conflict.syncUid))) {
for (const conflict of summary.conflicts) {
promptedConflictUids.add(conflict.syncUid);
}
conflictPromptVisible = true;
}
set({
status: 'idle',
lastSyncAt: summary.finishedAt,
lastSummary: summary,
conflicts: summary.conflicts,
conflictPromptVisible,
supported: true,
});
} catch (error) {
set({
status: 'error',
errorMessage:
error instanceof Error && error.message.trim() ? error.message : 'Desktop sync failed.',
supported: !(error instanceof DesktopSyncUnsupportedError),
});
}
},
resolveConflict: async (conflict, resolution) => {
if (get().status === 'syncing') return;
try {
await applyDesktopSyncConflictResolution(conflict, resolution);
} catch (error) {
set({
status: 'error',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: 'Failed to resolve the sync conflict.',
});
return;
}
// Drop it optimistically; the follow-up sync re-detects anything unsettled.
set((state) => ({
conflicts: state.conflicts.filter((entry) => entry.syncUid !== conflict.syncUid),
}));
await get().syncNow();
},
dismissConflictPrompt: () => {
set({ conflictPromptVisible: false });
},
handleSyncRequest: () => {
const { status } = get();
if (status === 'syncing') return;
void get().syncNow();
},
maybeAutoSync: (reason) => {
const { status, lastSyncAt, supported, autoSyncEnabled } = get();
if (!supported || !autoSyncEnabled) return;
if (status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
// Discovery/connect events arrive in bursts — coalesce them.
if (autoSyncDebounceTimer !== null) clearTimeout(autoSyncDebounceTimer);
autoSyncDebounceTimer = setTimeout(() => {
autoSyncDebounceTimer = null;
void (async () => {
const state = useDesktopSyncStore.getState();
if (state.status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
// Automatic triggers are speculative — probe reachability first so an
// off-LAN desktop doesn't surface an error banner from an attempt the
// user never asked for.
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
if (!identity) return;
void state.syncNow();
})();
}, AUTO_SYNC_DEBOUNCE_MS);
void reason;
},
}));
+23 -1
View File
@@ -1,18 +1,24 @@
export const DESKTOP_REMOTE_PROTOCOL_VERSION = 1;
export const DESKTOP_REMOTE_PROTOCOL_VERSION = 2;
export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all';
export type DesktopRemoteControlCommand =
| 'play'
| 'pause'
| 'next'
| 'previous'
| 'toggle-favorite'
| 'toggle-shuffle'
| 'toggle-repeat'
| 'seek';
export interface DesktopRemoteIdentity {
endpointUuid: string | null;
desktopName: string | null;
protocolVersion: number;
/** Set while a desktop-initiated library-sync request awaits pickup;
* read by the phone's periodic foreground probe. */
syncRequestedAt?: number | null;
}
export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
@@ -40,12 +46,28 @@ export interface DesktopRemoteNowPlayingSnapshot {
currentTime: number;
duration: number;
queueLength: number;
/** Absent on protocol-1 desktops — the UI hides the shuffle/repeat controls. */
shuffle?: boolean;
repeat?: DesktopRemoteRepeatMode;
outputDeviceLabel: string | null;
visualizerLineColor: string;
currentTrack: DesktopRemoteTrackSnapshot | null;
updatedAt: number;
}
export interface DesktopRemoteQueueItem {
queueId: string;
title: string;
artist: string;
durationSeconds: number | null;
isCurrent: boolean;
}
export interface DesktopRemoteQueueSnapshot {
items: DesktopRemoteQueueItem[];
updatedAt: number;
}
export interface DesktopRemotePairingClaim {
requestId: string;
pollToken: string;
+175
View File
@@ -0,0 +1,175 @@
// Wire types for the desktop<->mobile favorites/playlists LAN sync
// (GET /v1/sync/state, POST /v1/sync/apply on the paired desktop's
// phone-remote server). Mirrors the desktop's src/types/phoneSync.ts.
// Track-level items are keyed by the metadata identity key from
// shared/sync/identity.ts; playlists by sync_uid. Timestamps are wall-clock ms
// and travel verbatim between devices — last-writer-wins merges must never mix
// in the receiving side's clock.
export const DESKTOP_SYNC_FORMAT = 1;
/** Desktop protocol version that introduced /v1/sync/* (and queue/shuffle). */
export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 2;
export interface SyncFavorite {
key: string;
title: string;
artist: string;
album: string;
addedAt: number;
}
export interface SyncKeyTombstone {
key: string;
deletedAt: number;
}
export interface SyncPlaylistEntry {
title: string;
artist: string;
album: string;
durationSeconds: number | null;
position: number;
addedAt: number;
sourcePath: string | null;
}
export type SyncPlaylistKind = 'normal' | 'dynamic';
export interface SyncPlaylist {
syncUid: string;
name: string;
kind: SyncPlaylistKind;
dynamicRules: string | null;
createdAt: number;
updatedAt: number;
entries: SyncPlaylistEntry[] | null;
}
export interface SyncPlaylistSnapshot {
name: string;
kind: SyncPlaylistKind;
dynamicRules: string | null;
updatedAt: number;
trackCount: number;
entries: SyncPlaylistEntry[] | null;
}
export interface SyncUidTombstone {
syncUid: string;
deletedAt: number;
}
export interface DesktopSyncState {
syncFormat: number;
now: number;
favorites: SyncFavorite[];
favoriteTombstones: SyncKeyTombstone[];
playlists: SyncPlaylist[];
playlistTombstones: SyncUidTombstone[];
/** Conflict resolutions chosen on the desktop, awaiting this phone. */
pendingResolutions?: DesktopSyncPendingResolution[];
}
export interface DesktopSyncPendingResolution {
syncUid: string;
resolution: DesktopSyncConflictResolution;
decidedAt: number;
}
/** Phone→desktop conflict report (POST /v1/sync/conflicts) so the desktop can
* show conflicts and offer resolutions too. */
export interface DesktopSyncReportedConflict {
kind: DesktopSyncConflictKind;
syncUid: string;
name: string;
playlistKind: SyncPlaylistKind;
phoneName: string;
desktopName: string;
phoneUpdatedAt: number;
desktopUpdatedAt: number;
phoneTrackCount: number;
desktopTrackCount: number;
phoneSnapshot?: SyncPlaylistSnapshot | null;
desktopSnapshot?: SyncPlaylistSnapshot | null;
}
export interface DesktopSyncConflictReportPayload {
syncFormat: number;
conflicts: DesktopSyncReportedConflict[];
/** Uids of desktop-chosen resolutions this phone just applied. */
consumedResolutions: string[];
}
export interface DesktopSyncApplyPayload {
syncFormat: number;
favoriteAdds: SyncFavorite[];
favoriteRemoves: SyncKeyTombstone[];
playlistUpserts: SyncPlaylist[];
playlistDeletes: SyncUidTombstone[];
}
export type DesktopSyncPlaylistApplyStatus = 'created' | 'replaced' | 'deleted' | 'skipped-incompatible';
export interface DesktopSyncPlaylistApplyResult {
syncUid: string;
status: DesktopSyncPlaylistApplyStatus;
entriesMatched: number;
entriesFallback: number;
}
export interface DesktopSyncApplyResult {
ok: true;
favorites: {
added: number;
pending: number;
removed: number;
};
playlists: DesktopSyncPlaylistApplyResult[];
}
export type DesktopSyncConflictKind = 'first-pairing' | 'concurrent-edit';
export type DesktopSyncConflictResolution = 'desktop' | 'phone' | 'both' | 'merge';
/**
* A playlist change sync refuses to resolve automatically: either a
* first-pairing name collision with divergent contents, or both devices
* edited the same playlist since the last sync. Sync completes everything
* else and leaves both copies untouched until the user picks a resolution.
*/
export interface DesktopSyncPlaylistConflict {
kind: DesktopSyncConflictKind;
/** The desktop playlist's sync uid (the identity the pair shares/would share). */
syncUid: string;
localPlaylistId: number;
/** The local playlist's own uid at detection (differs from syncUid for first-pairing). */
localSyncUid: string;
name: string;
playlistKind: SyncPlaylistKind;
localName: string;
remoteName: string;
localUpdatedAt: number;
remoteUpdatedAt: number;
localTrackCount: number;
remoteTrackCount: number;
/** Snapshot of this phone's version at detection (for previews/reporting). */
local: SyncPlaylist;
/** Snapshot of the desktop version at detection (for keep-desktop / merge). */
remote: SyncPlaylist;
}
export interface DesktopSyncSummary {
favoritesAdded: number;
favoritesRemoved: number;
favoritesPending: number;
playlistsCreated: number;
playlistsReplaced: number;
playlistsDeleted: number;
playlistsSkipped: number;
entriesFallback: number;
pushedToDesktop: boolean;
conflicts: DesktopSyncPlaylistConflict[];
startedAt: number;
finishedAt: number;
}