mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
desktop sync support
This commit is contained in:
@@ -65,6 +65,7 @@
|
|||||||
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
|
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
|||||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
|
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||||
|
import { formatRelativeTime } from '@/lib/format';
|
||||||
import type { ReplayGainMode } from '@/audio/normalization';
|
import type { ReplayGainMode } from '@/audio/normalization';
|
||||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||||
import type { LastFmStatus } from '@/types/lastFm';
|
import type { LastFmStatus } from '@/types/lastFm';
|
||||||
@@ -253,6 +255,18 @@ export default function SettingsScreen() {
|
|||||||
return () => task.cancel();
|
return () => task.cancel();
|
||||||
}, [initDesktopRemote]);
|
}, [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
|
const desktopRemoteSubtitle = desktopRemoteConnection
|
||||||
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
||||||
: 'Pair with Astra Desktop to control playback from this phone.';
|
: 'Pair with Astra Desktop to control playback from this phone.';
|
||||||
@@ -403,6 +417,24 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
</Pressable>
|
</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
|
<Text
|
||||||
variant="label"
|
variant="label"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
@@ -31,6 +32,13 @@ import {
|
|||||||
setDesktopRemoteMediaSession,
|
setDesktopRemoteMediaSession,
|
||||||
subscribeDesktopRemoteMediaSessionCommands,
|
subscribeDesktopRemoteMediaSessionCommands,
|
||||||
} from '@/services/desktopRemoteMediaSession';
|
} 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';
|
import { colors } from '@/theme';
|
||||||
|
|
||||||
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
|
// 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;
|
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() {
|
export default function RootLayout() {
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Inter_400Regular,
|
Inter_400Regular,
|
||||||
@@ -176,6 +291,7 @@ export default function RootLayout() {
|
|||||||
<NormalizationSync />
|
<NormalizationSync />
|
||||||
<LastFmScrobbler />
|
<LastFmScrobbler />
|
||||||
<DesktopRemoteMediaSessionSync />
|
<DesktopRemoteMediaSessionSync />
|
||||||
|
<DesktopSyncAutoTrigger />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
@@ -194,6 +310,7 @@ export default function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<QuickSearchOverlay />
|
<QuickSearchOverlay />
|
||||||
|
<SyncConflictPrompt />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
+170
-49
@@ -16,7 +16,7 @@ import {
|
|||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
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 { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
@@ -24,21 +24,33 @@ import { MarqueeText } from '@/components/MarqueeText';
|
|||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { SeekBar } from '@/components/SeekBar';
|
import { SeekBar } from '@/components/SeekBar';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||||
import {
|
import {
|
||||||
colors,
|
colors,
|
||||||
radius,
|
radius,
|
||||||
spacing
|
spacing
|
||||||
} from '@/theme';
|
} from '@/theme';
|
||||||
|
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
|
||||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
|
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 MAX_CONTENT_WIDTH = 408;
|
||||||
|
const TABLET_MAX_CONTENT_WIDTH = 520;
|
||||||
|
const TABLET_ART_SIZE_MAX = 440;
|
||||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||||
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
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 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 HEADER_HEIGHT = 32;
|
||||||
const CONTENT_TOP_PADDING = spacing.sm;
|
const CONTENT_TOP_PADDING = spacing.sm;
|
||||||
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||||
@@ -56,9 +68,14 @@ const SUB_TOP_MARGIN = spacing.lg;
|
|||||||
const MIN_FLOATING_SPACE = spacing.sm;
|
const MIN_FLOATING_SPACE = spacing.sm;
|
||||||
|
|
||||||
interface RemoteLayout {
|
interface RemoteLayout {
|
||||||
|
isWide: boolean;
|
||||||
contentPadding: number;
|
contentPadding: number;
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
|
leftPaneWidth: number;
|
||||||
|
rightPaneWidth: number;
|
||||||
|
controlsGap: number;
|
||||||
artSize: number;
|
artSize: number;
|
||||||
|
mediaStackHeight: number;
|
||||||
mediaTopMargin: number;
|
mediaTopMargin: number;
|
||||||
mediaBottomGap: number;
|
mediaBottomGap: number;
|
||||||
}
|
}
|
||||||
@@ -67,21 +84,51 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(max, Math.max(min, value));
|
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 =
|
const contentPadding =
|
||||||
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||||
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
|
||||||
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
|
const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth));
|
||||||
const mediaFloor = availableHeight < 620 ? COMPACT_MEDIA_AREA_MIN : MEDIA_AREA_MIN;
|
const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth);
|
||||||
const mediaMin = Math.min(mediaMax, mediaFloor);
|
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
||||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||||
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||||
const fixedHeight =
|
const fixedHeightBase =
|
||||||
CONTENT_TOP_PADDING +
|
CONTENT_TOP_PADDING +
|
||||||
CONTENT_BOTTOM_PADDING +
|
CONTENT_BOTTOM_PADDING +
|
||||||
HEADER_HEIGHT +
|
HEADER_HEIGHT +
|
||||||
mediaTopMargin +
|
mediaTopMargin +
|
||||||
mediaBottomGap +
|
|
||||||
TRACK_INFO_ESTIMATE +
|
TRACK_INFO_ESTIMATE +
|
||||||
SEEK_BLOCK_ESTIMATE +
|
SEEK_BLOCK_ESTIMATE +
|
||||||
TRANSPORT_TOP_MARGIN +
|
TRANSPORT_TOP_MARGIN +
|
||||||
@@ -89,16 +136,19 @@ function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLa
|
|||||||
SUB_TOP_MARGIN +
|
SUB_TOP_MARGIN +
|
||||||
SUB_BUTTON_SIZE +
|
SUB_BUTTON_SIZE +
|
||||||
MIN_FLOATING_SPACE;
|
MIN_FLOATING_SPACE;
|
||||||
const heightBoundMedia = availableHeight - fixedHeight;
|
// The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat
|
||||||
const fitAwareMediaMin = Math.min(mediaMin, Math.max(96, heightBoundMedia));
|
// windows (split-screen halves) instead of pushing the controls off-screen.
|
||||||
const artSize = Math.min(
|
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
|
||||||
Math.round(clamp(heightBoundMedia, fitAwareMediaMin, mediaMax)),
|
const artSize = Math.round(clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax));
|
||||||
ART_SIZE_MAX
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
|
isWide: false,
|
||||||
contentPadding,
|
contentPadding,
|
||||||
contentWidth,
|
contentWidth,
|
||||||
|
leftPaneWidth: contentWidth,
|
||||||
|
rightPaneWidth: contentWidth,
|
||||||
|
controlsGap: TRANSPORT_TOP_MARGIN,
|
||||||
artSize,
|
artSize,
|
||||||
|
mediaStackHeight: artSize,
|
||||||
mediaTopMargin,
|
mediaTopMargin,
|
||||||
mediaBottomGap,
|
mediaBottomGap,
|
||||||
};
|
};
|
||||||
@@ -197,12 +247,14 @@ export default function DesktopRemoteScreen() {
|
|||||||
const reconnect = useDesktopRemoteStore((s) => s.reconnect);
|
const reconnect = useDesktopRemoteStore((s) => s.reconnect);
|
||||||
const forget = useDesktopRemoteStore((s) => s.forget);
|
const forget = useDesktopRemoteStore((s) => s.forget);
|
||||||
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
|
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||||
|
const queue = useDesktopRemoteStore((s) => s.queue);
|
||||||
|
|
||||||
const [pairingLink, setPairingLink] = useState('');
|
const [pairingLink, setPairingLink] = useState('');
|
||||||
const [pinInput, setPinInput] = useState('');
|
const [pinInput, setPinInput] = useState('');
|
||||||
const [pinClock, setPinClock] = useState(() => Date.now());
|
const [pinClock, setPinClock] = useState(() => Date.now());
|
||||||
const [manualBaseUrl, setManualBaseUrl] = useState('');
|
const [manualBaseUrl, setManualBaseUrl] = useState('');
|
||||||
const [manualTicket, setManualTicket] = useState('');
|
const [manualTicket, setManualTicket] = useState('');
|
||||||
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void init();
|
void init();
|
||||||
@@ -234,9 +286,16 @@ export default function DesktopRemoteScreen() {
|
|||||||
const art = currentTrack?.artworkDataUrl ?? null;
|
const art = currentTrack?.artworkDataUrl ?? null;
|
||||||
const accent = snapshot?.visualizerLineColor || colors.accent;
|
const accent = snapshot?.visualizerLineColor || colors.accent;
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
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 remoteSource = connection?.desktopName ?? 'Astra Desktop';
|
||||||
const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : '');
|
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 countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : '';
|
||||||
const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
|
const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
|
||||||
const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
|
const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
|
||||||
@@ -499,14 +558,17 @@ export default function DesktopRemoteScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{currentTrack ? (
|
{currentTrack ? (
|
||||||
<View style={styles.remotePlayer}>
|
<View style={[styles.remotePlayer, remoteLayout.isWide && styles.remotePlayerWide]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.middleStack,
|
styles.middleStack,
|
||||||
{
|
remoteLayout.isWide
|
||||||
marginTop: remoteLayout.mediaTopMargin,
|
? { width: remoteLayout.leftPaneWidth, justifyContent: 'center' }
|
||||||
marginBottom: remoteLayout.mediaBottomGap,
|
: {
|
||||||
},
|
height: remoteLayout.mediaStackHeight,
|
||||||
|
marginTop: remoteLayout.mediaTopMargin,
|
||||||
|
marginBottom: remoteLayout.mediaBottomGap,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -531,9 +593,14 @@ export default function DesktopRemoteScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View
|
||||||
|
style={[
|
||||||
<View style={styles.playerControls}>
|
styles.playerControls,
|
||||||
|
remoteLayout.isWide
|
||||||
|
? { width: remoteLayout.rightPaneWidth }
|
||||||
|
: styles.playerControlsFill,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.trackInfo}>
|
<View style={styles.trackInfo}>
|
||||||
<View style={styles.trackTextStack}>
|
<View style={styles.trackTextStack}>
|
||||||
<MarqueeText
|
<MarqueeText
|
||||||
@@ -547,6 +614,19 @@ export default function DesktopRemoteScreen() {
|
|||||||
{currentTrack.artist || currentTrack.album || remoteSource}
|
{currentTrack.artist || currentTrack.album || remoteSource}
|
||||||
</MarqueeText>
|
</MarqueeText>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
<SeekBar
|
<SeekBar
|
||||||
@@ -556,14 +636,20 @@ export default function DesktopRemoteScreen() {
|
|||||||
onSeek={(seconds) => void sendControl('seek', seconds)}
|
onSeek={(seconds) => void sendControl('seek', seconds)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.transport}>
|
<View style={[styles.transport, { marginTop: remoteLayout.controlsGap }]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.transportSideBtn}
|
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
||||||
onPress={() => void reconnect()}
|
disabled={!supportsShuffleRepeat}
|
||||||
accessibilityLabel="Reconnect"
|
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>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void sendControl('previous')}
|
onPress={() => void sendControl('previous')}
|
||||||
@@ -595,20 +681,29 @@ export default function DesktopRemoteScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.transportSideBtn}
|
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
||||||
onPress={() => void sendControl('toggle-favorite')}
|
disabled={!supportsShuffleRepeat}
|
||||||
accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
onPress={() => void sendControl('toggle-repeat')}
|
||||||
accessibilityState={{ selected: currentTrack.isFavorite }}
|
accessibilityLabel="Repeat"
|
||||||
|
accessibilityState={{ selected: repeatMode !== 'none' }}
|
||||||
>
|
>
|
||||||
<Ionicons
|
{repeatMode === 'one' ? (
|
||||||
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
|
<MaterialCommunityIcons
|
||||||
size={SUB_ICON_SIZE + 4}
|
name="repeat-once"
|
||||||
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
|
size={SUB_ICON_SIZE + 2}
|
||||||
/>
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name="repeat"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={repeatMode === 'all' ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.subRow}>
|
<View style={[styles.subRow, { marginTop: remoteLayout.controlsGap }]}>
|
||||||
<View style={styles.statusPill}>
|
<View style={styles.statusPill}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -623,6 +718,16 @@ export default function DesktopRemoteScreen() {
|
|||||||
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
|
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
|
||||||
{remoteDetail}
|
{remoteDetail}
|
||||||
</Text>
|
</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
|
<Pressable
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.subBtn}
|
style={styles.subBtn}
|
||||||
@@ -683,6 +788,7 @@ export default function DesktopRemoteScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
{queueOpen && <RemoteQueueSheet onClose={() => setQueueOpen(false)} />}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -882,6 +988,12 @@ const styles = StyleSheet.create({
|
|||||||
remotePlayer: {
|
remotePlayer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
remotePlayerWide: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
columnGap: WIDE_PANE_GAP,
|
||||||
|
},
|
||||||
middleStack: {
|
middleStack: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -897,13 +1009,15 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
spacer: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: MIN_FLOATING_SPACE,
|
|
||||||
},
|
|
||||||
playerControls: {
|
playerControls: {
|
||||||
width: '100%',
|
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: {
|
trackInfo: {
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -929,7 +1043,6 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginTop: TRANSPORT_TOP_MARGIN,
|
|
||||||
},
|
},
|
||||||
transportMainBtn: {
|
transportMainBtn: {
|
||||||
width: 48,
|
width: 48,
|
||||||
@@ -943,6 +1056,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
transportSideBtnDisabled: {
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
inlineActionBtn: {
|
||||||
|
width: SUB_BUTTON_SIZE,
|
||||||
|
height: SUB_BUTTON_SIZE,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
playButton: {
|
playButton: {
|
||||||
width: PLAY_BUTTON_SIZE,
|
width: PLAY_BUTTON_SIZE,
|
||||||
height: PLAY_BUTTON_SIZE,
|
height: PLAY_BUTTON_SIZE,
|
||||||
@@ -955,8 +1077,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: spacing.md,
|
gap: spacing.sm,
|
||||||
marginTop: SUB_TOP_MARGIN,
|
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
},
|
},
|
||||||
subBtn: {
|
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,
|
buildDynamicPlaylistOrderByClause,
|
||||||
buildDynamicPlaylistWhereClause,
|
buildDynamicPlaylistWhereClause,
|
||||||
} from './dynamicPlaylistSql';
|
} from './dynamicPlaylistSql';
|
||||||
|
import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity';
|
||||||
|
|
||||||
const PLAYLIST_SELECT = `
|
const PLAYLIST_SELECT = `
|
||||||
SELECT p.id, p.name, p.kind, p.dynamic_rules_json,
|
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> {
|
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).
|
// ON DELETE CASCADE removes the entries (foreign_keys is ON per connection).
|
||||||
await db.run('DELETE FROM playlists WHERE id = ?', [id]);
|
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);
|
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> {
|
export async function addFavorite(db: LibraryDatabase, trackPath: string): Promise<void> {
|
||||||
await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
|
await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [
|
||||||
trackPath,
|
trackPath,
|
||||||
Date.now(),
|
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> {
|
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]);
|
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> {
|
export async function addFavoritePaths(db: LibraryDatabase, paths: string[]): Promise<void> {
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const insertedPaths: string[] = [];
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
for (const path of paths) {
|
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,
|
path,
|
||||||
now,
|
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) ------------------------------
|
// --- Remote sync (Subsonic playlists/favorites) ------------------------------
|
||||||
|
|||||||
+44
-2
@@ -21,11 +21,18 @@
|
|||||||
// v15 adds `album_display_artist` — the settled group artist ("Various Artists" for
|
// v15 adds `album_display_artist` — the settled group artist ("Various Artists" for
|
||||||
// shared-artwork compilations) written by the album-identity recompute pass
|
// shared-artwork compilations) written by the album-identity recompute pass
|
||||||
// (src/library/albumIdentity.ts); the backfill itself runs from libraryStore.initialize
|
// (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';
|
import type { LibraryDatabase } from './database';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 15;
|
export const SCHEMA_VERSION = 17;
|
||||||
|
|
||||||
// One statement per entry — op-sqlite executes single statements.
|
// One statement per entry — op-sqlite executes single statements.
|
||||||
const MIGRATIONS: readonly (readonly string[])[] = [
|
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
|
// v14 -> v15 — settled album display artist (see header). NULL until the
|
||||||
// startup recompute pass backfills it; readers fall back to album_artist/artist.
|
// startup recompute pass backfills it; readers fall back to album_artist/artist.
|
||||||
[`ALTER TABLE tracks ADD COLUMN album_display_artist TEXT`],
|
[`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> {
|
export async function migrate(db: LibraryDatabase): Promise<void> {
|
||||||
|
|||||||
@@ -6,3 +6,15 @@ export function formatDuration(seconds: number): string {
|
|||||||
const s = total % 60;
|
const s = total % 60;
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
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
|
// M3U file IO (SAF export, document-picker import). The matching ladders live
|
||||||
// ladder: exact content URI -> decoded SAF path -> file name (+ path-suffix
|
// in importMatching.ts (pure, node-testable) and are re-exported here for the
|
||||||
// overlap) -> metadata (title+artist -> title, unique-or-null), ported in
|
// existing import sites.
|
||||||
// spirit from the desktop playlist importer.
|
|
||||||
|
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import {
|
import {
|
||||||
@@ -9,155 +8,18 @@ import {
|
|||||||
readAsStringAsync,
|
readAsStringAsync,
|
||||||
writeAsStringAsync,
|
writeAsStringAsync,
|
||||||
} from 'expo-file-system/legacy';
|
} 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';
|
import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u';
|
||||||
|
|
||||||
export const decodedDocPath = decodedSafDocumentPath;
|
export {
|
||||||
|
buildImportIndex,
|
||||||
// --- Import matching ---------------------------------------------------------
|
decodedDocPath,
|
||||||
|
matchImportEntry,
|
||||||
interface IndexedTrack {
|
matchSyncEntry,
|
||||||
track: DbTrack;
|
normalizeEntryPath,
|
||||||
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
|
type ImportMatch,
|
||||||
decodedPath: string | null;
|
type ImportMatchIndex,
|
||||||
}
|
type SyncEntryQuery,
|
||||||
|
} from './importMatching';
|
||||||
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' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- File IO -------------------------------------------------------------------
|
// --- File IO -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import type {
|
|||||||
DesktopRemotePairingClaim,
|
DesktopRemotePairingClaim,
|
||||||
DesktopRemotePairingStatus,
|
DesktopRemotePairingStatus,
|
||||||
DesktopRemotePinPairingRequest,
|
DesktopRemotePinPairingRequest,
|
||||||
|
DesktopRemoteQueueSnapshot,
|
||||||
} from '@/types/desktopRemote';
|
} from '@/types/desktopRemote';
|
||||||
|
import type {
|
||||||
|
DesktopSyncApplyPayload,
|
||||||
|
DesktopSyncApplyResult,
|
||||||
|
DesktopSyncConflictReportPayload,
|
||||||
|
DesktopSyncState,
|
||||||
|
} from '@/types/desktopSync';
|
||||||
export {
|
export {
|
||||||
parseDesktopRemoteManualInput,
|
parseDesktopRemoteManualInput,
|
||||||
parseDesktopRemotePairingInput,
|
parseDesktopRemotePairingInput,
|
||||||
@@ -102,6 +109,10 @@ function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null {
|
|||||||
typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
|
typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
|
||||||
? candidate.protocolVersion
|
? candidate.protocolVersion
|
||||||
: 1,
|
: 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 = {
|
export type DesktopRemoteSseHandlers = {
|
||||||
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void;
|
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;
|
onUnauthorized: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown) => void;
|
||||||
@@ -244,7 +310,9 @@ export type DesktopRemoteSseHandlers = {
|
|||||||
function processSseChunk(
|
function processSseChunk(
|
||||||
buffer: { value: string },
|
buffer: { value: string },
|
||||||
chunk: string,
|
chunk: string,
|
||||||
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void
|
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void,
|
||||||
|
onQueue?: (queue: DesktopRemoteQueueSnapshot) => void,
|
||||||
|
onSyncRequest?: () => void
|
||||||
): void {
|
): void {
|
||||||
buffer.value += chunk.replace(/\r/g, '');
|
buffer.value += chunk.replace(/\r/g, '');
|
||||||
let boundary = buffer.value.indexOf('\n\n');
|
let boundary = buffer.value.indexOf('\n\n');
|
||||||
@@ -267,6 +335,14 @@ function processSseChunk(
|
|||||||
} catch {
|
} catch {
|
||||||
// Ignore a malformed event; polling/reconnect will correct the UI.
|
// 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');
|
boundary = buffer.value.indexOf('\n\n');
|
||||||
}
|
}
|
||||||
@@ -307,9 +383,17 @@ export function startDesktopRemoteEventStream(
|
|||||||
while (!closed) {
|
while (!closed) {
|
||||||
const next = await reader.read();
|
const next = await reader.read();
|
||||||
if (next.done) break;
|
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();
|
if (!closed) handlers.onDisconnect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (closed || controller.signal.aborted) return;
|
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,
|
fetchDesktopRemoteIdentity,
|
||||||
fetchDesktopRemoteNowPlaying,
|
fetchDesktopRemoteNowPlaying,
|
||||||
fetchDesktopRemotePairingStatus,
|
fetchDesktopRemotePairingStatus,
|
||||||
|
fetchDesktopRemoteQueue,
|
||||||
parseDesktopRemoteManualInput,
|
parseDesktopRemoteManualInput,
|
||||||
parseDesktopRemotePairingInput,
|
parseDesktopRemotePairingInput,
|
||||||
requestDesktopRemotePinPairing,
|
requestDesktopRemotePinPairing,
|
||||||
sendDesktopRemoteControl,
|
sendDesktopRemoteControl,
|
||||||
|
sendDesktopRemotePlayQueueItem,
|
||||||
startDesktopRemoteEventStream,
|
startDesktopRemoteEventStream,
|
||||||
} from '@/services/desktopRemoteClient';
|
} from '@/services/desktopRemoteClient';
|
||||||
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
|
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
|
||||||
@@ -25,12 +27,16 @@ import {
|
|||||||
setDesktopRemoteConnection,
|
setDesktopRemoteConnection,
|
||||||
setDesktopRemoteToken,
|
setDesktopRemoteToken,
|
||||||
} from '@/services/desktopRemoteCredentials';
|
} from '@/services/desktopRemoteCredentials';
|
||||||
|
import { openLibraryDb } from '@/db/database';
|
||||||
|
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
|
||||||
|
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||||
import type {
|
import type {
|
||||||
DesktopRemoteConnection,
|
DesktopRemoteConnection,
|
||||||
DesktopRemoteControlCommand,
|
DesktopRemoteControlCommand,
|
||||||
DesktopRemoteDiscoveredDesktop,
|
DesktopRemoteDiscoveredDesktop,
|
||||||
DesktopRemoteIdentity,
|
DesktopRemoteIdentity,
|
||||||
DesktopRemoteNowPlayingSnapshot,
|
DesktopRemoteNowPlayingSnapshot,
|
||||||
|
DesktopRemoteQueueSnapshot,
|
||||||
} from '@/types/desktopRemote';
|
} from '@/types/desktopRemote';
|
||||||
|
|
||||||
const PAIR_POLL_INTERVAL_MS = 1500;
|
const PAIR_POLL_INTERVAL_MS = 1500;
|
||||||
@@ -66,6 +72,8 @@ interface DesktopRemoteStore {
|
|||||||
connection: DesktopRemoteConnection | null;
|
connection: DesktopRemoteConnection | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
snapshot: DesktopRemoteNowPlayingSnapshot | null;
|
snapshot: DesktopRemoteNowPlayingSnapshot | null;
|
||||||
|
/** Desktop queue (current + upcoming); null on protocol-1 desktops. */
|
||||||
|
queue: DesktopRemoteQueueSnapshot | null;
|
||||||
discovered: DesktopRemoteDiscoveredDesktop[];
|
discovered: DesktopRemoteDiscoveredDesktop[];
|
||||||
discoveryAvailable: boolean;
|
discoveryAvailable: boolean;
|
||||||
discoveryRunning: boolean;
|
discoveryRunning: boolean;
|
||||||
@@ -86,6 +94,8 @@ interface DesktopRemoteStore {
|
|||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
forget: () => Promise<void>;
|
forget: () => Promise<void>;
|
||||||
sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise<void>;
|
sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise<void>;
|
||||||
|
refreshQueue: () => Promise<void>;
|
||||||
|
playQueueItem: (queueId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pairingPollTimer: ReturnType<typeof setTimeout> | null = null;
|
let pairingPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -210,11 +220,15 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
if (!connection || !token || connectionState === 'connecting') return;
|
if (!connection || !token || connectionState === 'connecting') return;
|
||||||
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
|
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
|
||||||
(snapshot) => {
|
(snapshot) => {
|
||||||
|
const wasConnected = get().connectionState === 'connected';
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
snapshot: mergeSnapshotArtwork(state.snapshot, snapshot),
|
snapshot: mergeSnapshotArtwork(state.snapshot, snapshot),
|
||||||
connectionState: 'connected',
|
connectionState: 'connected',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
}));
|
}));
|
||||||
|
if (!wasConnected) {
|
||||||
|
useDesktopSyncStore.getState().maybeAutoSync('connected');
|
||||||
|
}
|
||||||
refreshInlineArtwork();
|
refreshInlineArtwork();
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -434,6 +448,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
connection: null,
|
connection: null,
|
||||||
token: null,
|
token: null,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
|
queue: null,
|
||||||
discovered: [],
|
discovered: [],
|
||||||
discoveryAvailable: desktopRemoteDiscoveryAvailable,
|
discoveryAvailable: desktopRemoteDiscoveryAvailable,
|
||||||
discoveryRunning: false,
|
discoveryRunning: false,
|
||||||
@@ -536,16 +551,27 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
message: '',
|
message: '',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
});
|
});
|
||||||
|
useDesktopSyncStore.getState().maybeAutoSync('connected');
|
||||||
stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
|
stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
|
||||||
onSnapshot: (nextSnapshot) => {
|
onSnapshot: (nextSnapshot) => {
|
||||||
|
const wasConnected = get().connectionState === 'connected';
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot),
|
snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot),
|
||||||
connectionState: 'connected',
|
connectionState: 'connected',
|
||||||
message: '',
|
message: '',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
}));
|
}));
|
||||||
|
if (!wasConnected) {
|
||||||
|
useDesktopSyncStore.getState().maybeAutoSync('connected');
|
||||||
|
}
|
||||||
refreshInlineArtwork();
|
refreshInlineArtwork();
|
||||||
},
|
},
|
||||||
|
onQueue: (queue) => {
|
||||||
|
set({ queue });
|
||||||
|
},
|
||||||
|
onSyncRequest: () => {
|
||||||
|
useDesktopSyncStore.getState().handleSyncRequest();
|
||||||
|
},
|
||||||
onUnauthorized: () => {
|
onUnauthorized: () => {
|
||||||
void get().forget();
|
void get().forget();
|
||||||
set({ errorMessage: 'Desktop pairing was revoked.' });
|
set({ errorMessage: 'Desktop pairing was revoked.' });
|
||||||
@@ -556,6 +582,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
scheduleSnapshotPoll();
|
scheduleSnapshotPoll();
|
||||||
|
void get().refreshQueue();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
|
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
|
||||||
@@ -580,18 +607,23 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
disconnect: () => {
|
disconnect: () => {
|
||||||
stopRealtime();
|
stopRealtime();
|
||||||
clearPairingPoll();
|
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 () => {
|
forget: async () => {
|
||||||
stopRealtime();
|
stopRealtime();
|
||||||
clearPairingPoll();
|
clearPairingPoll();
|
||||||
await clearDesktopRemotePairing();
|
await clearDesktopRemotePairing();
|
||||||
|
// Sync baselines are meaningless against a different desktop.
|
||||||
|
void openLibraryDb()
|
||||||
|
.then((db) => clearPlaylistSyncBaselines(db))
|
||||||
|
.catch(() => {});
|
||||||
set({
|
set({
|
||||||
connectionState: 'unpaired',
|
connectionState: 'unpaired',
|
||||||
connection: null,
|
connection: null,
|
||||||
token: null,
|
token: null,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
|
queue: null,
|
||||||
pairing: null,
|
pairing: null,
|
||||||
pinPairing: null,
|
pinPairing: null,
|
||||||
message: '',
|
message: '',
|
||||||
@@ -618,5 +650,32 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
set({ errorMessage: errorMessage(error) });
|
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 DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
|
||||||
|
export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all';
|
||||||
export type DesktopRemoteControlCommand =
|
export type DesktopRemoteControlCommand =
|
||||||
| 'play'
|
| 'play'
|
||||||
| 'pause'
|
| 'pause'
|
||||||
| 'next'
|
| 'next'
|
||||||
| 'previous'
|
| 'previous'
|
||||||
| 'toggle-favorite'
|
| 'toggle-favorite'
|
||||||
|
| 'toggle-shuffle'
|
||||||
|
| 'toggle-repeat'
|
||||||
| 'seek';
|
| 'seek';
|
||||||
|
|
||||||
export interface DesktopRemoteIdentity {
|
export interface DesktopRemoteIdentity {
|
||||||
endpointUuid: string | null;
|
endpointUuid: string | null;
|
||||||
desktopName: string | null;
|
desktopName: string | null;
|
||||||
protocolVersion: number;
|
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 {
|
export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
|
||||||
@@ -40,12 +46,28 @@ export interface DesktopRemoteNowPlayingSnapshot {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
queueLength: number;
|
queueLength: number;
|
||||||
|
/** Absent on protocol-1 desktops — the UI hides the shuffle/repeat controls. */
|
||||||
|
shuffle?: boolean;
|
||||||
|
repeat?: DesktopRemoteRepeatMode;
|
||||||
outputDeviceLabel: string | null;
|
outputDeviceLabel: string | null;
|
||||||
visualizerLineColor: string;
|
visualizerLineColor: string;
|
||||||
currentTrack: DesktopRemoteTrackSnapshot | null;
|
currentTrack: DesktopRemoteTrackSnapshot | null;
|
||||||
updatedAt: number;
|
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 {
|
export interface DesktopRemotePairingClaim {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
pollToken: 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