mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import { StyleSheet, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { Text } from '@/components/Text';
|
|
import { radius, spacing } from '@/theme';
|
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
|
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 styles = useStyles();
|
|
const colors = useColors();
|
|
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 styles = useStyles();
|
|
const colors = useColors();
|
|
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 styles = useStyles();
|
|
const colors = useColors();
|
|
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 useStyles = createThemedStyles((colors) => ({
|
|
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,
|
|
},
|
|
}));
|