mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
desktop sync support
This commit is contained in:
@@ -21,6 +21,8 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
import type { ReplayGainMode } from '@/audio/normalization';
|
||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||
import type { LastFmStatus } from '@/types/lastFm';
|
||||
@@ -253,6 +255,18 @@ export default function SettingsScreen() {
|
||||
return () => task.cancel();
|
||||
}, [initDesktopRemote]);
|
||||
|
||||
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
|
||||
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
||||
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
||||
const desktopSyncSubtitle = !desktopRemoteConnection
|
||||
? 'Sync favorites and playlists with Astra Desktop.'
|
||||
: desktopSyncConflictCount > 0
|
||||
? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve`
|
||||
: desktopSyncStatus === 'syncing'
|
||||
? 'Syncing…'
|
||||
: desktopLastSyncAt !== null
|
||||
? `Synced ${formatRelativeTime(desktopLastSyncAt)}`
|
||||
: `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`;
|
||||
const desktopRemoteSubtitle = desktopRemoteConnection
|
||||
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
||||
: 'Pair with Astra Desktop to control playback from this phone.';
|
||||
@@ -403,6 +417,24 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={() => router.push('/desktop-sync' as never)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="sync-outline" size={20} color={colors.textSecondary} />
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body">Desktop Sync</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={desktopSyncConflictCount > 0 ? colors.warning : colors.textSecondary}
|
||||
style={styles.optionDescription}
|
||||
>
|
||||
{desktopSyncSubtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
|
||||
<Text
|
||||
variant="label"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
@@ -31,6 +32,13 @@ import {
|
||||
setDesktopRemoteMediaSession,
|
||||
subscribeDesktopRemoteMediaSessionCommands,
|
||||
} from '@/services/desktopRemoteMediaSession';
|
||||
import {
|
||||
getDesktopRemoteConnection,
|
||||
setDesktopRemoteConnection,
|
||||
} from '@/services/desktopRemoteCredentials';
|
||||
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
|
||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
|
||||
@@ -107,6 +115,113 @@ function DesktopRemoteMediaSessionSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DESKTOP_DISCOVERY_BURST_MS = 20_000;
|
||||
const DESKTOP_SYNC_STARTUP_RETRY_MS = 2_500;
|
||||
const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Auto-syncs favorites/playlists with the paired desktop when it looks
|
||||
* reachable: on foreground (probe-guarded, min-interval limited), when mDNS
|
||||
* discovery spots the paired desktop, or when the remote screen connects.
|
||||
* Deliberately does NOT init the desktop-remote store here — its connect path
|
||||
* retries a powered-off desktop every 2 s forever, which we don't want running
|
||||
* from app launch.
|
||||
*/
|
||||
function DesktopSyncAutoTrigger() {
|
||||
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||
const discovered = useDesktopRemoteStore((s) => s.discovered);
|
||||
|
||||
useEffect(() => {
|
||||
void useDesktopSyncStore.getState().hydrate();
|
||||
}, []);
|
||||
|
||||
// Cold start + each return to foreground: attempt a (probe-guarded) sync and
|
||||
// run a short mDNS burst so a desktop that changed LAN address is found.
|
||||
useEffect(() => {
|
||||
let burstTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startupRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onActive = () => {
|
||||
void (async () => {
|
||||
const connection = await getDesktopRemoteConnection();
|
||||
if (!connection) return;
|
||||
useDesktopSyncStore.getState().maybeAutoSync('foreground');
|
||||
const remote = useDesktopRemoteStore.getState();
|
||||
if (remote.discoveryAvailable && !remote.discoveryRunning) {
|
||||
void remote.startDiscovery();
|
||||
burstTimer = setTimeout(() => {
|
||||
burstTimer = null;
|
||||
void useDesktopRemoteStore.getState().stopDiscovery();
|
||||
}, DESKTOP_DISCOVERY_BURST_MS);
|
||||
}
|
||||
})();
|
||||
};
|
||||
if (AppState.currentState === 'active') {
|
||||
onActive();
|
||||
} else {
|
||||
startupRetryTimer = setTimeout(() => {
|
||||
startupRetryTimer = null;
|
||||
if (AppState.currentState === 'active') onActive();
|
||||
}, DESKTOP_SYNC_STARTUP_RETRY_MS);
|
||||
}
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') onActive();
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
if (burstTimer !== null) clearTimeout(burstTimer);
|
||||
if (startupRetryTimer !== null) clearTimeout(startupRetryTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Desktop-initiated "Sync now" pickup: a cheap identity poll while
|
||||
// foregrounded (the SSE nudge only reaches us while the remote screen's
|
||||
// stream happens to be connected). fetchDesktopRemoteIdentity swallows
|
||||
// errors, so a powered-off desktop costs one timed-out request per minute.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (AppState.currentState !== 'active') return;
|
||||
void (async () => {
|
||||
const connection = await getDesktopRemoteConnection();
|
||||
if (!connection) return;
|
||||
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
|
||||
if (identity?.syncRequestedAt) {
|
||||
useDesktopSyncStore.getState().handleSyncRequest();
|
||||
}
|
||||
})();
|
||||
}, DESKTOP_SYNC_REQUEST_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves)
|
||||
// and trigger a sync.
|
||||
useEffect(() => {
|
||||
if (discovered.length === 0) return;
|
||||
void (async () => {
|
||||
const connection = await getDesktopRemoteConnection();
|
||||
if (!connection?.endpointUuid) return;
|
||||
const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid);
|
||||
if (!match) return;
|
||||
if (match.baseUrl && match.baseUrl !== connection.baseUrl) {
|
||||
const updated = { ...connection, baseUrl: match.baseUrl };
|
||||
await setDesktopRemoteConnection(updated);
|
||||
if (useDesktopRemoteStore.getState().connection) {
|
||||
useDesktopRemoteStore.setState({ connection: updated });
|
||||
}
|
||||
}
|
||||
useDesktopSyncStore.getState().maybeAutoSync('discovery');
|
||||
})();
|
||||
}, [discovered]);
|
||||
|
||||
// The remote screen connected — the desktop is definitely reachable.
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected') {
|
||||
useDesktopSyncStore.getState().maybeAutoSync('connected');
|
||||
}
|
||||
}, [connectionState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
@@ -176,6 +291,7 @@ export default function RootLayout() {
|
||||
<NormalizationSync />
|
||||
<LastFmScrobbler />
|
||||
<DesktopRemoteMediaSessionSync />
|
||||
<DesktopSyncAutoTrigger />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -194,6 +310,7 @@ export default function RootLayout() {
|
||||
/>
|
||||
</Stack>
|
||||
<QuickSearchOverlay />
|
||||
<SyncConflictPrompt />
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
+170
-49
@@ -16,7 +16,7 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
@@ -24,21 +24,33 @@ import { MarqueeText } from '@/components/MarqueeText';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { SeekBar } from '@/components/SeekBar';
|
||||
import { Text } from '@/components/Text';
|
||||
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||
import {
|
||||
colors,
|
||||
radius,
|
||||
spacing
|
||||
} from '@/theme';
|
||||
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
|
||||
|
||||
// Layout mirrors now-playing's adaptive pattern (minus the scope stage): a
|
||||
// wide two-pane branch, and a portrait branch where the controls own ALL real
|
||||
// leftover space (flex + space-between) so height-estimate error spreads
|
||||
// between the control rows instead of pooling as one dead gap.
|
||||
const MAX_CONTENT_WIDTH = 408;
|
||||
const TABLET_MAX_CONTENT_WIDTH = 520;
|
||||
const TABLET_ART_SIZE_MAX = 440;
|
||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
||||
const WIDE_MAX_CONTENT_WIDTH = 960;
|
||||
const WIDE_PANE_GAP = spacing.xxl;
|
||||
const WIDE_RIGHT_PANE_MIN = 300;
|
||||
const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH;
|
||||
const WIDE_ART_SIZE_MAX = 400;
|
||||
const WIDE_ART_SIZE_MIN = 160;
|
||||
const WIDE_COMPACT_HEIGHT = 480;
|
||||
const MEDIA_AREA_MIN = 220;
|
||||
const COMPACT_MEDIA_AREA_MIN = 128;
|
||||
const MEDIA_AREA_MAX = 360;
|
||||
const ART_SIZE_MAX = 340;
|
||||
const HEADER_HEIGHT = 32;
|
||||
const CONTENT_TOP_PADDING = spacing.sm;
|
||||
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||
@@ -56,9 +68,14 @@ const SUB_TOP_MARGIN = spacing.lg;
|
||||
const MIN_FLOATING_SPACE = spacing.sm;
|
||||
|
||||
interface RemoteLayout {
|
||||
isWide: boolean;
|
||||
contentPadding: number;
|
||||
contentWidth: number;
|
||||
leftPaneWidth: number;
|
||||
rightPaneWidth: number;
|
||||
controlsGap: number;
|
||||
artSize: number;
|
||||
mediaStackHeight: number;
|
||||
mediaTopMargin: number;
|
||||
mediaBottomGap: number;
|
||||
}
|
||||
@@ -67,21 +84,51 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLayout {
|
||||
function getRemoteLayout(availableWidth: number, availableHeight: number): RemoteLayout {
|
||||
if (isWideWindow(availableWidth, availableHeight)) {
|
||||
const contentPadding = CONTENT_SIDE_PADDING;
|
||||
const contentWidth = Math.max(
|
||||
0,
|
||||
Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH)
|
||||
);
|
||||
const rightPaneWidth = Math.round(
|
||||
clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX)
|
||||
);
|
||||
const leftPaneWidth = Math.max(0, contentWidth - WIDE_PANE_GAP - rightPaneWidth);
|
||||
const verticalBudget =
|
||||
availableHeight - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md;
|
||||
const artSize = Math.round(
|
||||
clamp(Math.min(leftPaneWidth, verticalBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX)
|
||||
);
|
||||
return {
|
||||
isWide: true,
|
||||
contentPadding,
|
||||
contentWidth,
|
||||
leftPaneWidth,
|
||||
rightPaneWidth,
|
||||
controlsGap: availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg,
|
||||
artSize,
|
||||
mediaStackHeight: artSize,
|
||||
mediaTopMargin: 0,
|
||||
mediaBottomGap: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Tall windows: single column. Tablet-width ones get a larger column/art cap.
|
||||
const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH;
|
||||
const contentPadding =
|
||||
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
||||
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
|
||||
const mediaFloor = availableHeight < 620 ? COMPACT_MEDIA_AREA_MIN : MEDIA_AREA_MIN;
|
||||
const mediaMin = Math.min(mediaMax, mediaFloor);
|
||||
availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||
const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH;
|
||||
const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth));
|
||||
const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth);
|
||||
const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN);
|
||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||
const fixedHeight =
|
||||
const fixedHeightBase =
|
||||
CONTENT_TOP_PADDING +
|
||||
CONTENT_BOTTOM_PADDING +
|
||||
HEADER_HEIGHT +
|
||||
mediaTopMargin +
|
||||
mediaBottomGap +
|
||||
TRACK_INFO_ESTIMATE +
|
||||
SEEK_BLOCK_ESTIMATE +
|
||||
TRANSPORT_TOP_MARGIN +
|
||||
@@ -89,16 +136,19 @@ function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLa
|
||||
SUB_TOP_MARGIN +
|
||||
SUB_BUTTON_SIZE +
|
||||
MIN_FLOATING_SPACE;
|
||||
const heightBoundMedia = availableHeight - fixedHeight;
|
||||
const fitAwareMediaMin = Math.min(mediaMin, Math.max(96, heightBoundMedia));
|
||||
const artSize = Math.min(
|
||||
Math.round(clamp(heightBoundMedia, fitAwareMediaMin, mediaMax)),
|
||||
ART_SIZE_MAX
|
||||
);
|
||||
// The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat
|
||||
// windows (split-screen halves) instead of pushing the controls off-screen.
|
||||
const bound = availableHeight - fixedHeightBase - mediaBottomGap;
|
||||
const artSize = Math.round(clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax));
|
||||
return {
|
||||
isWide: false,
|
||||
contentPadding,
|
||||
contentWidth,
|
||||
leftPaneWidth: contentWidth,
|
||||
rightPaneWidth: contentWidth,
|
||||
controlsGap: TRANSPORT_TOP_MARGIN,
|
||||
artSize,
|
||||
mediaStackHeight: artSize,
|
||||
mediaTopMargin,
|
||||
mediaBottomGap,
|
||||
};
|
||||
@@ -197,12 +247,14 @@ export default function DesktopRemoteScreen() {
|
||||
const reconnect = useDesktopRemoteStore((s) => s.reconnect);
|
||||
const forget = useDesktopRemoteStore((s) => s.forget);
|
||||
const sendControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||
const queue = useDesktopRemoteStore((s) => s.queue);
|
||||
|
||||
const [pairingLink, setPairingLink] = useState('');
|
||||
const [pinInput, setPinInput] = useState('');
|
||||
const [pinClock, setPinClock] = useState(() => Date.now());
|
||||
const [manualBaseUrl, setManualBaseUrl] = useState('');
|
||||
const [manualTicket, setManualTicket] = useState('');
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void init();
|
||||
@@ -234,9 +286,16 @@ export default function DesktopRemoteScreen() {
|
||||
const art = currentTrack?.artworkDataUrl ?? null;
|
||||
const accent = snapshot?.visualizerLineColor || colors.accent;
|
||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||
const remoteLayout = getRemoteLayout(windowWidth, availableHeight);
|
||||
const effectiveWidth = windowWidth - insets.left - insets.right;
|
||||
const remoteLayout = getRemoteLayout(effectiveWidth, availableHeight);
|
||||
const remoteSource = connection?.desktopName ?? 'Astra Desktop';
|
||||
const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : '');
|
||||
// Live protocol gate: protocol-1 desktops omit shuffle/repeat from the
|
||||
// snapshot and 404 the queue endpoint (queue stays null).
|
||||
const supportsShuffleRepeat = snapshot?.shuffle !== undefined;
|
||||
const shuffleOn = snapshot?.shuffle === true;
|
||||
const repeatMode = snapshot?.repeat ?? 'none';
|
||||
const queueAvailable = queue !== null;
|
||||
const countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : '';
|
||||
const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
|
||||
const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
|
||||
@@ -499,14 +558,17 @@ export default function DesktopRemoteScreen() {
|
||||
</View>
|
||||
|
||||
{currentTrack ? (
|
||||
<View style={styles.remotePlayer}>
|
||||
<View style={[styles.remotePlayer, remoteLayout.isWide && styles.remotePlayerWide]}>
|
||||
<View
|
||||
style={[
|
||||
styles.middleStack,
|
||||
{
|
||||
marginTop: remoteLayout.mediaTopMargin,
|
||||
marginBottom: remoteLayout.mediaBottomGap,
|
||||
},
|
||||
remoteLayout.isWide
|
||||
? { width: remoteLayout.leftPaneWidth, justifyContent: 'center' }
|
||||
: {
|
||||
height: remoteLayout.mediaStackHeight,
|
||||
marginTop: remoteLayout.mediaTopMargin,
|
||||
marginBottom: remoteLayout.mediaBottomGap,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
@@ -531,9 +593,14 @@ export default function DesktopRemoteScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
|
||||
<View style={styles.playerControls}>
|
||||
<View
|
||||
style={[
|
||||
styles.playerControls,
|
||||
remoteLayout.isWide
|
||||
? { width: remoteLayout.rightPaneWidth }
|
||||
: styles.playerControlsFill,
|
||||
]}
|
||||
>
|
||||
<View style={styles.trackInfo}>
|
||||
<View style={styles.trackTextStack}>
|
||||
<MarqueeText
|
||||
@@ -547,6 +614,19 @@ export default function DesktopRemoteScreen() {
|
||||
{currentTrack.artist || currentTrack.album || remoteSource}
|
||||
</MarqueeText>
|
||||
</View>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.inlineActionBtn}
|
||||
onPress={() => void sendControl('toggle-favorite')}
|
||||
accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
accessibilityState={{ selected: currentTrack.isFavorite }}
|
||||
>
|
||||
<Ionicons
|
||||
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={SUB_ICON_SIZE + 4}
|
||||
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<SeekBar
|
||||
@@ -556,14 +636,20 @@ export default function DesktopRemoteScreen() {
|
||||
onSeek={(seconds) => void sendControl('seek', seconds)}
|
||||
/>
|
||||
|
||||
<View style={styles.transport}>
|
||||
<View style={[styles.transport, { marginTop: remoteLayout.controlsGap }]}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn}
|
||||
onPress={() => void reconnect()}
|
||||
accessibilityLabel="Reconnect"
|
||||
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
||||
disabled={!supportsShuffleRepeat}
|
||||
onPress={() => void sendControl('toggle-shuffle')}
|
||||
accessibilityLabel="Shuffle"
|
||||
accessibilityState={{ selected: shuffleOn }}
|
||||
>
|
||||
<Ionicons name="refresh" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
|
||||
<Ionicons
|
||||
name="shuffle"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={shuffleOn ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void sendControl('previous')}
|
||||
@@ -595,20 +681,29 @@ export default function DesktopRemoteScreen() {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn}
|
||||
onPress={() => void sendControl('toggle-favorite')}
|
||||
accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
accessibilityState={{ selected: currentTrack.isFavorite }}
|
||||
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
||||
disabled={!supportsShuffleRepeat}
|
||||
onPress={() => void sendControl('toggle-repeat')}
|
||||
accessibilityLabel="Repeat"
|
||||
accessibilityState={{ selected: repeatMode !== 'none' }}
|
||||
>
|
||||
<Ionicons
|
||||
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={SUB_ICON_SIZE + 4}
|
||||
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
{repeatMode === 'one' ? (
|
||||
<MaterialCommunityIcons
|
||||
name="repeat-once"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={colors.accent}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name="repeat"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={repeatMode === 'all' ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.subRow}>
|
||||
<View style={[styles.subRow, { marginTop: remoteLayout.controlsGap }]}>
|
||||
<View style={styles.statusPill}>
|
||||
<View
|
||||
style={[
|
||||
@@ -623,6 +718,16 @@ export default function DesktopRemoteScreen() {
|
||||
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
|
||||
{remoteDetail}
|
||||
</Text>
|
||||
{queueAvailable ? (
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => setQueueOpen(true)}
|
||||
accessibilityLabel="Desktop queue"
|
||||
>
|
||||
<Ionicons name="list-outline" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
@@ -683,6 +788,7 @@ export default function DesktopRemoteScreen() {
|
||||
</>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
{queueOpen && <RemoteQueueSheet onClose={() => setQueueOpen(false)} />}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -882,6 +988,12 @@ const styles = StyleSheet.create({
|
||||
remotePlayer: {
|
||||
flex: 1,
|
||||
},
|
||||
remotePlayerWide: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
columnGap: WIDE_PANE_GAP,
|
||||
},
|
||||
middleStack: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
@@ -897,13 +1009,15 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minHeight: MIN_FLOATING_SPACE,
|
||||
},
|
||||
playerControls: {
|
||||
width: '100%',
|
||||
},
|
||||
// Portrait: the controls own all real leftover space; spare pixels spread
|
||||
// evenly between the rows instead of pooling above the track title.
|
||||
playerControlsFill: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
trackInfo: {
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
@@ -929,7 +1043,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: TRANSPORT_TOP_MARGIN,
|
||||
},
|
||||
transportMainBtn: {
|
||||
width: 48,
|
||||
@@ -943,6 +1056,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
transportSideBtnDisabled: {
|
||||
opacity: 0.35,
|
||||
},
|
||||
inlineActionBtn: {
|
||||
width: SUB_BUTTON_SIZE,
|
||||
height: SUB_BUTTON_SIZE,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playButton: {
|
||||
width: PLAY_BUTTON_SIZE,
|
||||
height: PLAY_BUTTON_SIZE,
|
||||
@@ -955,8 +1077,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: SUB_TOP_MARGIN,
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
subBtn: {
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// Desktop Sync — favorites/playlists library sync with the paired desktop.
|
||||
// Separate surface from the Desktop Remote controller: it shares the pairing
|
||||
// (one paired desktop serves both features) but nothing else. Conflict
|
||||
// resolution (Steam-Cloud style) lives inline here; the desktop mirrors the
|
||||
// same conflicts in its own settings and either side may resolve them.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
|
||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||
import {
|
||||
buildSyncConflictResolutionPreview,
|
||||
syncPlaylistToSnapshot,
|
||||
} from '@/shared/sync/conflictPreview';
|
||||
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
|
||||
import type {
|
||||
DesktopSyncConflictResolution,
|
||||
DesktopSyncPlaylistConflict,
|
||||
} from '@/types/desktopSync';
|
||||
|
||||
const RESOLUTION_LABELS: Record<DesktopSyncConflictResolution, string> = {
|
||||
desktop: 'Use desktop',
|
||||
phone: 'Use phone',
|
||||
both: 'Keep both',
|
||||
merge: 'Combine songs',
|
||||
};
|
||||
|
||||
function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] {
|
||||
return conflict.playlistKind === 'dynamic'
|
||||
? ['desktop', 'phone', 'both']
|
||||
: ['desktop', 'phone', 'both', 'merge'];
|
||||
}
|
||||
|
||||
function conflictDescription(conflict: DesktopSyncPlaylistConflict): string {
|
||||
if (conflict.kind === 'first-pairing') {
|
||||
return conflict.playlistKind === 'dynamic'
|
||||
? 'Exists on both devices with different rules.'
|
||||
: 'Exists on both devices with different songs.';
|
||||
}
|
||||
return 'Edited on both devices since the last sync.';
|
||||
}
|
||||
|
||||
function ConflictCard({
|
||||
conflict,
|
||||
desktopName,
|
||||
busy,
|
||||
onResolve,
|
||||
}: {
|
||||
conflict: DesktopSyncPlaylistConflict;
|
||||
desktopName: string;
|
||||
busy: boolean;
|
||||
onResolve: (resolution: DesktopSyncConflictResolution) => void;
|
||||
}) {
|
||||
const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null);
|
||||
const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
|
||||
const phoneSnapshot = syncPlaylistToSnapshot(conflict.local);
|
||||
const options = resolutionOptions(conflict);
|
||||
const preview = selectedResolution
|
||||
? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot)
|
||||
: null;
|
||||
return (
|
||||
<View style={styles.conflictCard}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{conflict.localName}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{conflictDescription(conflict)}
|
||||
</Text>
|
||||
<View style={styles.conflictActions}>
|
||||
{options.map((resolution) => (
|
||||
<Pressable
|
||||
key={resolution}
|
||||
style={[
|
||||
styles.conflictBtn,
|
||||
selectedResolution === resolution ? styles.conflictBtnSelected : null,
|
||||
busy && styles.disabled,
|
||||
]}
|
||||
disabled={busy}
|
||||
onPress={() => setSelectedResolution(resolution)}
|
||||
>
|
||||
<Text variant="label">{RESOLUTION_LABELS[resolution]}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.previewBox}>
|
||||
<Text variant="label">
|
||||
{preview ? preview.title : 'Choose an option to preview it'}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{preview ? preview.detail : 'Nothing changes until you confirm.'}
|
||||
</Text>
|
||||
</View>
|
||||
<SyncConflictDetails
|
||||
conflict={conflict}
|
||||
desktopName={desktopName}
|
||||
maxRows={5}
|
||||
previewResolution={selectedResolution}
|
||||
/>
|
||||
<View style={styles.conflictConfirmRow}>
|
||||
<Pressable
|
||||
style={[styles.primaryButton, (!selectedResolution || busy) && styles.disabled]}
|
||||
disabled={!selectedResolution || busy}
|
||||
onPress={() => selectedResolution ? onResolve(selectedResolution) : undefined}
|
||||
>
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Confirm
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopSyncScreen() {
|
||||
const router = useRouter();
|
||||
const status = useDesktopSyncStore((s) => s.status);
|
||||
const lastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
||||
const lastSummary = useDesktopSyncStore((s) => s.lastSummary);
|
||||
const conflicts = useDesktopSyncStore((s) => s.conflicts);
|
||||
const errorMessage = useDesktopSyncStore((s) => s.errorMessage);
|
||||
const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled);
|
||||
const syncNow = useDesktopSyncStore((s) => s.syncNow);
|
||||
const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled);
|
||||
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
|
||||
|
||||
const [connection, setConnection] = useState<DesktopRemoteConnection | null>(null);
|
||||
const [connectionLoaded, setConnectionLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void useDesktopSyncStore.getState().hydrate();
|
||||
void getDesktopRemoteConnection().then((stored) => {
|
||||
setConnection(stored);
|
||||
setConnectionLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const desktopName = connection?.desktopName ?? 'Astra Desktop';
|
||||
const syncing = status === 'syncing';
|
||||
const summaryLine = lastSummary
|
||||
? [
|
||||
lastSummary.favoritesAdded > 0 ? `${lastSummary.favoritesAdded} favorites added` : null,
|
||||
lastSummary.favoritesRemoved > 0 ? `${lastSummary.favoritesRemoved} removed` : null,
|
||||
lastSummary.playlistsCreated + lastSummary.playlistsReplaced > 0
|
||||
? `${lastSummary.playlistsCreated + lastSummary.playlistsReplaced} playlists updated`
|
||||
: null,
|
||||
lastSummary.playlistsDeleted > 0 ? `${lastSummary.playlistsDeleted} playlists removed` : null,
|
||||
lastSummary.favoritesPending > 0 ? `${lastSummary.favoritesPending} pending a library match` : null,
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Settings
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<View style={styles.hero}>
|
||||
<Ionicons name="sync-outline" size={30} color={colors.accent} />
|
||||
<View style={styles.heroText}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Desktop Sync
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Keep favorites and playlists in step with Astra Desktop over your LAN.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!connectionLoaded ? (
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
) : !connection ? (
|
||||
<View style={styles.card}>
|
||||
<Text variant="body">No desktop paired</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Sync uses the same pairing as the Desktop Remote. Pair this phone with Astra Desktop
|
||||
once and both features work.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => router.push('/desktop-remote' as never)}
|
||||
>
|
||||
<Ionicons name="link-outline" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Pair with a desktop
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardHeaderText}>
|
||||
<Text variant="body">{desktopName}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{syncing
|
||||
? 'Syncing…'
|
||||
: lastSyncAt !== null
|
||||
? `Synced ${formatRelativeTime(lastSyncAt)}`
|
||||
: 'Not synced yet'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.primaryButton, syncing && styles.disabled]}
|
||||
disabled={syncing}
|
||||
onPress={() => void syncNow()}
|
||||
accessibilityLabel="Sync favorites and playlists now"
|
||||
>
|
||||
{syncing ? (
|
||||
<ActivityIndicator size="small" color={colors.accentTextStrong} />
|
||||
) : (
|
||||
<Ionicons name="sync-outline" size={18} color={colors.accentTextStrong} />
|
||||
)}
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Sync now
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{summaryLine ? (
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Last sync: {summaryLine}
|
||||
</Text>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<Text variant="caption" color={colors.warning}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleText}>
|
||||
<Text variant="body">Sync automatically</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Sync when this desktop appears on the network or the app returns to the
|
||||
foreground. Manual and desktop-requested syncs always work.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autoSyncEnabled}
|
||||
onValueChange={(value) => void setAutoSyncEnabled(value)}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{conflicts.length > 0 ? (
|
||||
<View style={styles.card}>
|
||||
<Text variant="body">
|
||||
{conflicts.length === 1 ? '1 conflict' : `${conflicts.length} conflicts`} to
|
||||
resolve
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
These playlists differ between devices. Nothing changes until you choose —
|
||||
everything else already synced. You can also resolve these from the desktop
|
||||
settings.
|
||||
</Text>
|
||||
{conflicts.map((conflict) => (
|
||||
<ConflictCard
|
||||
key={conflict.syncUid}
|
||||
conflict={conflict}
|
||||
desktopName={desktopName}
|
||||
busy={syncing}
|
||||
onResolve={(resolution) => void resolveConflict(conflict, resolution)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
back: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: spacing.xxl,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
heroText: {
|
||||
flex: 1,
|
||||
},
|
||||
heading: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
card: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardHeaderText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
cardCopy: {
|
||||
lineHeight: 19,
|
||||
},
|
||||
primaryButton: {
|
||||
minHeight: 44,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
toggleText: {
|
||||
flex: 1,
|
||||
},
|
||||
conflictCard: {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
padding: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
conflictActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
conflictBtn: {
|
||||
minHeight: 36,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
conflictBtnSelected: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.glassHighlight,
|
||||
},
|
||||
previewBox: {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
padding: spacing.md,
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
conflictConfirmRow: {
|
||||
alignItems: 'flex-end',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user