mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
desktop sync support
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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 -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user