mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
improve desktop remote
This commit is contained in:
@@ -25,6 +25,7 @@ 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 { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
||||||
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
|
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +76,23 @@ function LastFmScrobbler() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads the selected playback target and connects remote only when desktop is selected. */
|
||||||
|
function PlaybackTargetSync() {
|
||||||
|
const target = usePlaybackTargetStore((s) => s.target);
|
||||||
|
const loadTarget = usePlaybackTargetStore((s) => s.load);
|
||||||
|
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTarget();
|
||||||
|
}, [loadTarget]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (target === 'desktop') void initDesktopRemote();
|
||||||
|
}, [target, initDesktopRemote]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Mirrors Desktop Remote now-playing into a separate Android MediaSession. */
|
/** Mirrors Desktop Remote now-playing into a separate Android MediaSession. */
|
||||||
function DesktopRemoteMediaSessionSync() {
|
function DesktopRemoteMediaSessionSync() {
|
||||||
const connection = useDesktopRemoteStore((s) => s.connection);
|
const connection = useDesktopRemoteStore((s) => s.connection);
|
||||||
@@ -290,6 +308,7 @@ export default function RootLayout() {
|
|||||||
<ScopeLifecycle />
|
<ScopeLifecycle />
|
||||||
<NormalizationSync />
|
<NormalizationSync />
|
||||||
<LastFmScrobbler />
|
<LastFmScrobbler />
|
||||||
|
<PlaybackTargetSync />
|
||||||
<DesktopRemoteMediaSessionSync />
|
<DesktopRemoteMediaSessionSync />
|
||||||
<DesktopSyncAutoTrigger />
|
<DesktopSyncAutoTrigger />
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
+92
-208
@@ -16,15 +16,12 @@ import {
|
|||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { Ionicons } 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';
|
||||||
import { MarqueeText } from '@/components/MarqueeText';
|
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
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,
|
||||||
@@ -59,11 +56,8 @@ const MEDIA_BOTTOM_GAP = spacing.xl;
|
|||||||
const TRACK_INFO_ESTIMATE = 96;
|
const TRACK_INFO_ESTIMATE = 96;
|
||||||
const SEEK_BLOCK_ESTIMATE = 54;
|
const SEEK_BLOCK_ESTIMATE = 54;
|
||||||
const PLAY_BUTTON_SIZE = 68;
|
const PLAY_BUTTON_SIZE = 68;
|
||||||
const SKIP_ICON_SIZE = 32;
|
|
||||||
const PLAY_ICON_SIZE = 34;
|
|
||||||
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
||||||
const SUB_BUTTON_SIZE = 40;
|
const SUB_BUTTON_SIZE = 40;
|
||||||
const SUB_ICON_SIZE = 20;
|
|
||||||
const SUB_TOP_MARGIN = spacing.lg;
|
const SUB_TOP_MARGIN = spacing.lg;
|
||||||
const MIN_FLOATING_SPACE = spacing.sm;
|
const MIN_FLOATING_SPACE = spacing.sm;
|
||||||
|
|
||||||
@@ -246,15 +240,12 @@ export default function DesktopRemoteScreen() {
|
|||||||
const pairManual = useDesktopRemoteStore((s) => s.pairManual);
|
const pairManual = useDesktopRemoteStore((s) => s.pairManual);
|
||||||
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 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();
|
||||||
@@ -281,21 +272,13 @@ export default function DesktopRemoteScreen() {
|
|||||||
}, [pinPairing]);
|
}, [pinPairing]);
|
||||||
|
|
||||||
const currentTrack = snapshot?.currentTrack ?? null;
|
const currentTrack = snapshot?.currentTrack ?? null;
|
||||||
const isPlaying = snapshot?.playbackState === 'playing';
|
|
||||||
const isBusy = connectionState === 'pairing' || connectionState === 'pendingApproval' || connectionState === 'connecting';
|
const isBusy = connectionState === 'pairing' || connectionState === 'pendingApproval' || connectionState === 'connecting';
|
||||||
const art = currentTrack?.artworkDataUrl ?? null;
|
const art = currentTrack?.artworkDataUrl ?? null;
|
||||||
const accent = snapshot?.visualizerLineColor || colors.accent;
|
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||||
const effectiveWidth = windowWidth - insets.left - insets.right;
|
const effectiveWidth = windowWidth - insets.left - insets.right;
|
||||||
const remoteLayout = getRemoteLayout(effectiveWidth, availableHeight);
|
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) : '';
|
||||||
@@ -541,7 +524,7 @@ export default function DesktopRemoteScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerMid}>
|
<View style={styles.headerMid}>
|
||||||
<Text variant="caption" style={styles.eyebrow}>
|
<Text variant="caption" style={styles.eyebrow}>
|
||||||
PLAYING FROM
|
DESKTOP REMOTE
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="label" numberOfLines={1} style={styles.source}>
|
<Text variant="label" numberOfLines={1} style={styles.source}>
|
||||||
{remoteSource}
|
{remoteSource}
|
||||||
@@ -557,199 +540,65 @@ export default function DesktopRemoteScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{currentTrack ? (
|
<View style={styles.managePanel}>
|
||||||
<View style={[styles.remotePlayer, remoteLayout.isWide && styles.remotePlayerWide]}>
|
<View style={styles.manageArt}>
|
||||||
<View
|
{art ? (
|
||||||
style={[
|
<Image
|
||||||
styles.middleStack,
|
key={currentTrack?.id}
|
||||||
remoteLayout.isWide
|
source={{ uri: art }}
|
||||||
? { width: remoteLayout.leftPaneWidth, justifyContent: 'center' }
|
style={styles.artImage}
|
||||||
: {
|
contentFit="cover"
|
||||||
height: remoteLayout.mediaStackHeight,
|
|
||||||
marginTop: remoteLayout.mediaTopMargin,
|
|
||||||
marginBottom: remoteLayout.mediaBottomGap,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.artCard,
|
|
||||||
{
|
|
||||||
width: remoteLayout.artSize,
|
|
||||||
height: remoteLayout.artSize,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{art ? (
|
|
||||||
<Image
|
|
||||||
key={currentTrack.id}
|
|
||||||
source={{ uri: art }}
|
|
||||||
style={styles.artImage}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AstraLogo size={Math.round(remoteLayout.artSize * 0.4)} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.playerControls,
|
|
||||||
remoteLayout.isWide
|
|
||||||
? { width: remoteLayout.rightPaneWidth }
|
|
||||||
: styles.playerControlsFill,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.trackInfo}>
|
|
||||||
<View style={styles.trackTextStack}>
|
|
||||||
<MarqueeText
|
|
||||||
variant="heading"
|
|
||||||
containerStyle={styles.trackTitle}
|
|
||||||
style={styles.trackTitleText}
|
|
||||||
>
|
|
||||||
{currentTrack.title}
|
|
||||||
</MarqueeText>
|
|
||||||
<MarqueeText variant="body" style={styles.artist}>
|
|
||||||
{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
|
|
||||||
currentTime={snapshot?.currentTime ?? 0}
|
|
||||||
duration={snapshot?.duration ?? 0}
|
|
||||||
trackKey={currentTrack.id}
|
|
||||||
onSeek={(seconds) => void sendControl('seek', seconds)}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
<View style={[styles.transport, { marginTop: remoteLayout.controlsGap }]}>
|
<AstraLogo size={52} />
|
||||||
<Pressable
|
)}
|
||||||
hitSlop={10}
|
|
||||||
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
|
||||||
disabled={!supportsShuffleRepeat}
|
|
||||||
onPress={() => void sendControl('toggle-shuffle')}
|
|
||||||
accessibilityLabel="Shuffle"
|
|
||||||
accessibilityState={{ selected: shuffleOn }}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="shuffle"
|
|
||||||
size={SUB_ICON_SIZE + 2}
|
|
||||||
color={shuffleOn ? colors.accent : colors.textTertiary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void sendControl('previous')}
|
|
||||||
hitSlop={12}
|
|
||||||
style={styles.transportMainBtn}
|
|
||||||
accessibilityLabel="Previous"
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-back" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void sendControl(isPlaying ? 'pause' : 'play')}
|
|
||||||
hitSlop={12}
|
|
||||||
style={[styles.playButton, { backgroundColor: accent }]}
|
|
||||||
accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={snapshot?.playbackState === 'loading' ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
|
||||||
size={PLAY_ICON_SIZE}
|
|
||||||
color={colors.bgPrimary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => void sendControl('next')}
|
|
||||||
hitSlop={12}
|
|
||||||
style={styles.transportMainBtn}
|
|
||||||
accessibilityLabel="Next"
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-forward" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
hitSlop={10}
|
|
||||||
style={[styles.transportSideBtn, !supportsShuffleRepeat && styles.transportSideBtnDisabled]}
|
|
||||||
disabled={!supportsShuffleRepeat}
|
|
||||||
onPress={() => void sendControl('toggle-repeat')}
|
|
||||||
accessibilityLabel="Repeat"
|
|
||||||
accessibilityState={{ selected: repeatMode !== 'none' }}
|
|
||||||
>
|
|
||||||
{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, { marginTop: remoteLayout.controlsGap }]}>
|
|
||||||
<View style={styles.statusPill}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.statusDot,
|
|
||||||
{ backgroundColor: connectionState === 'connected' ? accent : colors.warning },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text variant="label" color={colors.textSecondary}>
|
|
||||||
{connectionLabel(connectionState)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<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}
|
|
||||||
onPress={confirmForget}
|
|
||||||
accessibilityLabel="Forget desktop"
|
|
||||||
>
|
|
||||||
<Ionicons name="trash-outline" size={SUB_ICON_SIZE + 2} color={colors.warning} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<View style={styles.remoteEmpty}>
|
<View style={styles.manageText}>
|
||||||
<AstraLogo size={72} />
|
<Text variant="heading" numberOfLines={1} style={styles.emptyTitle}>
|
||||||
<Text variant="heading" style={styles.emptyTitle}>
|
{remoteSource}
|
||||||
Nothing playing
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
<Text variant="body" color={colors.textSecondary} numberOfLines={2} style={styles.centered}>
|
||||||
{statusText}
|
{currentTrack
|
||||||
|
? `${currentTrack.title}${currentTrack.artist ? ` · ${currentTrack.artist}` : ''}`
|
||||||
|
: remoteDetail || statusText}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
<View style={styles.statusPill}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: connectionState === 'connected' ? colors.accent : colors.warning },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={colors.textSecondary}>
|
||||||
|
{connectionLabel(connectionState)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.manageActions}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.primaryButton}
|
||||||
|
onPress={() => router.replace('/now-playing' as never)}
|
||||||
|
>
|
||||||
|
<Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} />
|
||||||
|
<Text variant="body" color={colors.accentTextStrong}>
|
||||||
|
Open Now Playing
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.secondaryButton} onPress={() => void reconnect()}>
|
||||||
|
<Ionicons name="refresh" size={18} color={colors.textPrimary} />
|
||||||
|
<Text variant="body">Reconnect</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.dangerButton} onPress={confirmForget}>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.warning} />
|
||||||
|
<Text variant="body" color={colors.warning}>
|
||||||
|
Forget desktop
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
@@ -788,7 +637,6 @@ export default function DesktopRemoteScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
{queueOpen && <RemoteQueueSheet onClose={() => setQueueOpen(false)} />}
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -869,6 +717,18 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
dangerButton: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.55,
|
opacity: 0.55,
|
||||||
},
|
},
|
||||||
@@ -970,6 +830,30 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
|
managePanel: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.lg,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
manageArt: {
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
manageText: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
manageActions: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
statusPill: {
|
statusPill: {
|
||||||
height: 30,
|
height: 30,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
|
|||||||
+359
-21
@@ -23,10 +23,13 @@ import { AstraLogo } from '@/components/AstraLogo';
|
|||||||
import { FormatBadges } from '@/components/FormatBadge';
|
import { FormatBadges } from '@/components/FormatBadge';
|
||||||
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
|
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
|
||||||
import { MarqueeText } from '@/components/MarqueeText';
|
import { MarqueeText } from '@/components/MarqueeText';
|
||||||
|
import { SeekBar } from '@/components/SeekBar';
|
||||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||||
import { Visualizer } from '@/components/Visualizer';
|
import { Visualizer } from '@/components/Visualizer';
|
||||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||||
|
import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
|
||||||
import { QueueTray } from '@/components/queue/QueueTray';
|
import { QueueTray } from '@/components/queue/QueueTray';
|
||||||
|
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
|
||||||
import {
|
import {
|
||||||
colors,
|
colors,
|
||||||
radius,
|
radius,
|
||||||
@@ -36,8 +39,10 @@ import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
|
|||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
import { resolveNavigationArtist } from '@/library/artistGrouping';
|
import { resolveNavigationArtist } from '@/library/artistGrouping';
|
||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||||
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import type { DbTrack } from '@/types/library';
|
import type { DbTrack } from '@/types/library';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +53,13 @@ import {
|
|||||||
togglePlay,
|
togglePlay,
|
||||||
toggleShuffle
|
toggleShuffle
|
||||||
} from '@/audio/playbackController';
|
} from '@/audio/playbackController';
|
||||||
|
import {
|
||||||
|
desktopConnectionLabel,
|
||||||
|
getDesktopPlaybackPresentation,
|
||||||
|
getEffectivePlaybackPresentation,
|
||||||
|
getPhonePlaybackPresentation,
|
||||||
|
hostFromBaseUrl,
|
||||||
|
} from '@/playback/playbackTargetPresentation';
|
||||||
|
|
||||||
const DISMISS_DISTANCE = 140;
|
const DISMISS_DISTANCE = 140;
|
||||||
const DISMISS_VELOCITY = 1000;
|
const DISMISS_VELOCITY = 1000;
|
||||||
@@ -257,7 +269,9 @@ export default function NowPlayingScreen() {
|
|||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||||
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
||||||
|
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||||
@@ -271,13 +285,41 @@ export default function NowPlayingScreen() {
|
|||||||
const repeat = usePlayerStore((s) => s.repeat);
|
const repeat = usePlayerStore((s) => s.repeat);
|
||||||
const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
|
const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
|
||||||
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
||||||
|
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
|
||||||
|
const desktopConnectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||||
|
const desktopSnapshot = useDesktopRemoteStore((s) => s.snapshot);
|
||||||
|
const desktopQueue = useDesktopRemoteStore((s) => s.queue);
|
||||||
|
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||||
|
const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect);
|
||||||
|
|
||||||
const isPlaying = playbackState === 'playing';
|
const phonePresentation = getPhonePlaybackPresentation({
|
||||||
const isLoading = playbackState === 'loading';
|
track,
|
||||||
|
playbackState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
const desktopPresentation = getDesktopPlaybackPresentation({
|
||||||
|
connection: desktopConnection,
|
||||||
|
connectionState: desktopConnectionState,
|
||||||
|
snapshot: desktopSnapshot,
|
||||||
|
});
|
||||||
|
const activePresentation = getEffectivePlaybackPresentation({
|
||||||
|
selectedTarget,
|
||||||
|
phone: phonePresentation,
|
||||||
|
desktop: desktopPresentation,
|
||||||
|
});
|
||||||
|
const isDesktopTarget = activePresentation.target === 'desktop';
|
||||||
|
const activeTrack = desktopSnapshot?.currentTrack ?? null;
|
||||||
|
const isPlaying = activePresentation.playbackState === 'playing';
|
||||||
|
const isLoading = activePresentation.playbackState === 'loading';
|
||||||
const availableHeight = windowHeight - insets.top - insets.bottom;
|
const availableHeight = windowHeight - insets.top - insets.bottom;
|
||||||
const effectiveWidth = windowWidth - insets.left - insets.right;
|
const effectiveWidth = windowWidth - insets.left - insets.right;
|
||||||
const layout = getNowPlayingLayout(effectiveWidth, availableHeight, scopeStageVisible);
|
const layout = getNowPlayingLayout(
|
||||||
const source = track?.album?.trim() ? track.album : 'Library';
|
effectiveWidth,
|
||||||
|
availableHeight,
|
||||||
|
isDesktopTarget ? false : scopeStageVisible
|
||||||
|
);
|
||||||
|
const source = activePresentation.sourceLabel;
|
||||||
const shellRight =
|
const shellRight =
|
||||||
insets.right +
|
insets.right +
|
||||||
layout.contentPadding +
|
layout.contentPadding +
|
||||||
@@ -312,7 +354,16 @@ export default function NowPlayingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const menuItems: NowPlayingMenuItem[] = [];
|
const menuItems: NowPlayingMenuItem[] = [];
|
||||||
if (artistName) {
|
menuItems.push({
|
||||||
|
key: 'output',
|
||||||
|
label: 'Choose output device',
|
||||||
|
icon: isDesktopTarget ? 'desktop-outline' : 'phone-portrait-outline',
|
||||||
|
onPress: () => {
|
||||||
|
closeMenu();
|
||||||
|
setTargetPickerOpen(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!isDesktopTarget && artistName) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'artist',
|
key: 'artist',
|
||||||
label: 'View artist',
|
label: 'View artist',
|
||||||
@@ -323,7 +374,7 @@ export default function NowPlayingScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (albumKey) {
|
if (!isDesktopTarget && albumKey) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'album',
|
key: 'album',
|
||||||
label: 'View album',
|
label: 'View album',
|
||||||
@@ -334,7 +385,7 @@ export default function NowPlayingScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (libraryTrack) {
|
if (!isDesktopTarget && libraryTrack) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'add-to-playlist',
|
key: 'add-to-playlist',
|
||||||
label: 'Add to playlist...',
|
label: 'Add to playlist...',
|
||||||
@@ -445,9 +496,11 @@ export default function NowPlayingScreen() {
|
|||||||
>
|
>
|
||||||
<View style={[styles.shell, { width: layout.contentWidth }]}>
|
<View style={[styles.shell, { width: layout.contentWidth }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
<View style={styles.headerSide}>
|
||||||
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
|
||||||
</Pressable>
|
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
<View style={styles.headerMid}>
|
<View style={styles.headerMid}>
|
||||||
<Text variant="caption" style={styles.eyebrow}>
|
<Text variant="caption" style={styles.eyebrow}>
|
||||||
PLAYING FROM
|
PLAYING FROM
|
||||||
@@ -456,18 +509,252 @@ export default function NowPlayingScreen() {
|
|||||||
{source}
|
{source}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<View style={[styles.headerSide, styles.headerActions]}>
|
||||||
style={styles.headerBtn}
|
<Pressable
|
||||||
onPress={openMenu}
|
style={styles.headerBtn}
|
||||||
disabled={menuItems.length === 0}
|
onPress={openMenu}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
accessibilityLabel="More options"
|
accessibilityLabel="More options"
|
||||||
>
|
>
|
||||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{track ? (
|
{isDesktopTarget ? (
|
||||||
|
activeTrack ? (
|
||||||
|
<View style={[styles.player, layout.isWide && styles.playerWide]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.middleStack,
|
||||||
|
layout.isWide
|
||||||
|
? { width: layout.leftPaneWidth, justifyContent: 'center' }
|
||||||
|
: {
|
||||||
|
height: layout.mediaStackHeight,
|
||||||
|
marginTop: layout.mediaTopMargin,
|
||||||
|
marginBottom: layout.mediaBottomGap,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.artCard,
|
||||||
|
{
|
||||||
|
width: layout.artSize,
|
||||||
|
height: layout.artSize,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{activePresentation.artworkUri ? (
|
||||||
|
<Image
|
||||||
|
key={activeTrack.id}
|
||||||
|
source={{ uri: activePresentation.artworkUri }}
|
||||||
|
style={styles.artImage}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.playerControls,
|
||||||
|
layout.isWide
|
||||||
|
? { width: layout.rightPaneWidth }
|
||||||
|
: styles.playerControlsFill,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.trackInfo, { marginBottom: layout.trackInfoGap }]}>
|
||||||
|
<View style={styles.trackTextStack}>
|
||||||
|
<MarqueeText
|
||||||
|
variant="heading"
|
||||||
|
containerStyle={styles.trackTitle}
|
||||||
|
style={styles.trackTitleText}
|
||||||
|
>
|
||||||
|
{activeTrack.title}
|
||||||
|
</MarqueeText>
|
||||||
|
<MarqueeText variant="body" style={styles.artist}>
|
||||||
|
{activeTrack.artist || activeTrack.album || activePresentation.deviceLabel}
|
||||||
|
</MarqueeText>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.inlineActionBtn}
|
||||||
|
onPress={() => void sendDesktopControl('toggle-favorite')}
|
||||||
|
accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
accessibilityState={{ selected: activeTrack.isFavorite }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={activeTrack.isFavorite ? 'heart' : 'heart-outline'}
|
||||||
|
size={SUB_ICON_SIZE + 4}
|
||||||
|
color={activeTrack.isFavorite ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SeekBar
|
||||||
|
currentTime={activePresentation.currentTime}
|
||||||
|
duration={activePresentation.duration}
|
||||||
|
trackKey={activeTrack.id}
|
||||||
|
onSeek={(seconds) => void sendDesktopControl('seek', seconds)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={[styles.transport, { marginTop: layout.controlsGap }]}>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={[
|
||||||
|
styles.transportSideBtn,
|
||||||
|
desktopSnapshot?.shuffle === undefined && styles.controlDisabled,
|
||||||
|
]}
|
||||||
|
disabled={desktopSnapshot?.shuffle === undefined}
|
||||||
|
onPress={() => void sendDesktopControl('toggle-shuffle')}
|
||||||
|
accessibilityLabel="Shuffle"
|
||||||
|
accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="shuffle"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={desktopSnapshot?.shuffle ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void sendDesktopControl('previous')}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.transportMainBtn}
|
||||||
|
accessibilityLabel="Previous"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-back"
|
||||||
|
size={SKIP_ICON_SIZE}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void sendDesktopControl(isPlaying ? 'pause' : 'play')}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.playButton}
|
||||||
|
accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||||
|
size={PLAY_ICON_SIZE}
|
||||||
|
color={colors.bgPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void sendDesktopControl('next')}
|
||||||
|
hitSlop={12}
|
||||||
|
style={styles.transportMainBtn}
|
||||||
|
accessibilityLabel="Next"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-forward"
|
||||||
|
size={SKIP_ICON_SIZE}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={[
|
||||||
|
styles.transportSideBtn,
|
||||||
|
desktopSnapshot?.repeat === undefined && styles.controlDisabled,
|
||||||
|
]}
|
||||||
|
disabled={desktopSnapshot?.repeat === undefined}
|
||||||
|
onPress={() => void sendDesktopControl('toggle-repeat')}
|
||||||
|
accessibilityLabel="Repeat"
|
||||||
|
accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }}
|
||||||
|
>
|
||||||
|
{desktopSnapshot?.repeat === 'one' ? (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="repeat-once"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name="repeat"
|
||||||
|
size={SUB_ICON_SIZE + 2}
|
||||||
|
color={desktopSnapshot?.repeat === 'all' ? colors.accent : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.subRow, { marginTop: layout.controlsGap }]}>
|
||||||
|
<View style={styles.statusPill}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
desktopConnectionState === 'connected' ? colors.accent : colors.warning,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={colors.textSecondary}>
|
||||||
|
{desktopConnectionLabel(desktopConnectionState)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={colors.textTertiary}
|
||||||
|
numberOfLines={1}
|
||||||
|
style={styles.remoteDetail}
|
||||||
|
>
|
||||||
|
{desktopSnapshot?.outputDeviceLabel?.trim() ||
|
||||||
|
(desktopConnection ? hostFromBaseUrl(desktopConnection.baseUrl) : '')}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.subActions}>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.subBtn}
|
||||||
|
onPress={() => void reconnectDesktop()}
|
||||||
|
accessibilityLabel="Reconnect to desktop"
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
|
{desktopQueue ? (
|
||||||
|
<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}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<AstraLogo size={72} />
|
||||||
|
<Text variant="heading" style={styles.emptyTitle}>
|
||||||
|
{desktopConnection ? 'Nothing playing on desktop' : 'No desktop paired'}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
||||||
|
{desktopConnection
|
||||||
|
? desktopConnectionLabel(desktopConnectionState)
|
||||||
|
: 'Pair with Astra Desktop to control it here.'}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.emptyAction}
|
||||||
|
onPress={() =>
|
||||||
|
desktopConnection
|
||||||
|
? void reconnectDesktop()
|
||||||
|
: router.push('/desktop-remote' as never)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>
|
||||||
|
{desktopConnection ? 'Reconnect' : 'Pair desktop'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : track ? (
|
||||||
<View style={[styles.player, layout.isWide && styles.playerWide]}>
|
<View style={[styles.player, layout.isWide && styles.playerWide]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -757,7 +1044,17 @@ export default function NowPlayingScreen() {
|
|||||||
initialStep="pickPlaylist"
|
initialStep="pickPlaylist"
|
||||||
onClose={() => setPlaylistActionTrack(null)}
|
onClose={() => setPlaylistActionTrack(null)}
|
||||||
/>
|
/>
|
||||||
{queueOpen && <QueueTray onClose={() => setQueueOpen(false)} />}
|
{queueOpen && (
|
||||||
|
isDesktopTarget ? (
|
||||||
|
<RemoteQueueSheet onClose={() => setQueueOpen(false)} />
|
||||||
|
) : (
|
||||||
|
<QueueTray onClose={() => setQueueOpen(false)} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<PlaybackTargetPicker
|
||||||
|
visible={targetPickerOpen}
|
||||||
|
onClose={() => setTargetPickerOpen(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -787,6 +1084,14 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
headerSide: {
|
||||||
|
width: 48,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerActions: {
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
headerMid: {
|
headerMid: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -952,6 +1257,9 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
controlDisabled: {
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
playButton: {
|
playButton: {
|
||||||
width: PLAY_BUTTON_SIZE,
|
width: PLAY_BUTTON_SIZE,
|
||||||
height: PLAY_BUTTON_SIZE,
|
height: PLAY_BUTTON_SIZE,
|
||||||
@@ -981,6 +1289,24 @@ const styles = StyleSheet.create({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
gap: spacing.lg,
|
gap: spacing.lg,
|
||||||
},
|
},
|
||||||
|
statusPill: {
|
||||||
|
minHeight: 28,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
remoteDetail: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
subBtn: {
|
subBtn: {
|
||||||
width: SUB_BUTTON_SIZE,
|
width: SUB_BUTTON_SIZE,
|
||||||
height: SUB_BUTTON_SIZE,
|
height: SUB_BUTTON_SIZE,
|
||||||
@@ -992,4 +1318,16 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
emptyAction: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TrackPlayer, {
|
|||||||
import type { PlaybackState, Track } from '@/types/audio';
|
import type { PlaybackState, Track } from '@/types/audio';
|
||||||
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
|
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
|
||||||
import { useQueueStore } from '@/stores/queueStore';
|
import { useQueueStore } from '@/stores/queueStore';
|
||||||
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import { setupPlayer } from './trackPlayer';
|
import { setupPlayer } from './trackPlayer';
|
||||||
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
|
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +121,10 @@ function syncOriginalOrderFromMirrorIfUnshuffled(): void {
|
|||||||
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
|
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPhonePlaybackTarget(): void {
|
||||||
|
void usePlaybackTargetStore.getState().setTarget('phone');
|
||||||
|
}
|
||||||
|
|
||||||
/** Fisher–Yates shuffle a copy of the array. */
|
/** Fisher–Yates shuffle a copy of the array. */
|
||||||
function shuffleArray<T>(items: readonly T[]): T[] {
|
function shuffleArray<T>(items: readonly T[]): T[] {
|
||||||
const out = [...items];
|
const out = [...items];
|
||||||
@@ -158,6 +163,7 @@ async function playTracksInternal(
|
|||||||
options: { allowBackgroundSetup: boolean },
|
options: { allowBackgroundSetup: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady(options);
|
await ensurePlayerReady(options);
|
||||||
originalOrder = tracks.map((t) => t.id);
|
originalOrder = tracks.map((t) => t.id);
|
||||||
// Honor an already-on shuffle by scrambling the upcoming tail of the new
|
// Honor an already-on shuffle by scrambling the upcoming tail of the new
|
||||||
@@ -182,6 +188,7 @@ async function playTracksInternal(
|
|||||||
/** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
|
/** Shuffle a context and play from the top (the library/album "Shuffle" buttons). */
|
||||||
export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady();
|
await ensurePlayerReady();
|
||||||
originalOrder = tracks.map((t) => t.id);
|
originalOrder = tracks.map((t) => t.id);
|
||||||
usePlayerStore.getState().setShuffle(true);
|
usePlayerStore.getState().setShuffle(true);
|
||||||
@@ -200,6 +207,7 @@ export async function shuffleTracks(tracks: Track[]): Promise<void> {
|
|||||||
|
|
||||||
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
|
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
|
||||||
export async function playSample(): Promise<void> {
|
export async function playSample(): Promise<void> {
|
||||||
|
selectPhonePlaybackTarget();
|
||||||
await ensurePlayerReady();
|
await ensurePlayerReady();
|
||||||
await queueLoadSettled();
|
await queueLoadSettled();
|
||||||
const queue = await TrackPlayer.getQueue();
|
const queue = await TrackPlayer.getQueue();
|
||||||
@@ -224,6 +232,7 @@ export async function playSample(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function play(): Promise<void> {
|
export async function play(): Promise<void> {
|
||||||
|
selectPhonePlaybackTarget();
|
||||||
usePlayerStore.getState().setPlaybackState('playing');
|
usePlayerStore.getState().setPlaybackState('playing');
|
||||||
try {
|
try {
|
||||||
await TrackPlayer.play();
|
await TrackPlayer.play();
|
||||||
@@ -453,6 +462,7 @@ export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex:
|
|||||||
|
|
||||||
/** Jump to (and play) an absolute queue index. */
|
/** Jump to (and play) an absolute queue index. */
|
||||||
export async function jumpToQueueIndex(index: number): Promise<void> {
|
export async function jumpToQueueIndex(index: number): Promise<void> {
|
||||||
|
selectPhonePlaybackTarget();
|
||||||
// Mid-fill, the tapped row may not be in the native queue yet (or may sit at
|
// Mid-fill, the tapped row may not be in the native queue yet (or may sit at
|
||||||
// a shifted native index while the head is still prepending) — translate,
|
// a shifted native index while the head is still prepending) — translate,
|
||||||
// waiting out the fill only when the target isn't loaded.
|
// waiting out the fill only when the target isn't loaded.
|
||||||
|
|||||||
+134
-61
@@ -17,9 +17,17 @@ import {
|
|||||||
spacing
|
spacing
|
||||||
} from '@/theme';
|
} from '@/theme';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
import { skipToNext, togglePlay } from '@/audio/playbackController';
|
||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||||
|
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
||||||
|
import {
|
||||||
|
getDesktopPlaybackPresentation,
|
||||||
|
getEffectivePlaybackPresentation,
|
||||||
|
getPhonePlaybackPresentation,
|
||||||
|
} from '@/playback/playbackTargetPresentation';
|
||||||
|
|
||||||
const PILL_HEIGHT = 56;
|
const PILL_HEIGHT = 56;
|
||||||
const ART = 42;
|
const ART = 42;
|
||||||
@@ -54,81 +62,146 @@ function MiniProgress({
|
|||||||
*/
|
*/
|
||||||
export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
export function MiniPlayer({ visible = true }: MiniPlayerProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||||
const track = usePlayerStore((s) => s.currentTrack);
|
const track = usePlayerStore((s) => s.currentTrack);
|
||||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
const duration = usePlayerStore((s) => s.duration);
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
|
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
|
||||||
|
const desktopConnectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||||
|
const desktopSnapshot = useDesktopRemoteStore((s) => s.snapshot);
|
||||||
|
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||||
|
const connectDesktop = useDesktopRemoteStore((s) => s.connect);
|
||||||
|
|
||||||
const scopeActive = useScopeActive();
|
const scopeActive = useScopeActive();
|
||||||
const [pillWidth, setPillWidth] = useState(0);
|
const [pillWidth, setPillWidth] = useState(0);
|
||||||
|
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||||
|
|
||||||
if (!track) return null;
|
const phonePresentation = getPhonePlaybackPresentation({
|
||||||
|
track,
|
||||||
|
playbackState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
const desktopPresentation = getDesktopPlaybackPresentation({
|
||||||
|
connection: desktopConnection,
|
||||||
|
connectionState: desktopConnectionState,
|
||||||
|
snapshot: desktopSnapshot,
|
||||||
|
});
|
||||||
|
const presentation = getEffectivePlaybackPresentation({
|
||||||
|
selectedTarget,
|
||||||
|
phone: phonePresentation,
|
||||||
|
desktop: desktopPresentation,
|
||||||
|
});
|
||||||
|
|
||||||
const isPlaying = playbackState === 'playing';
|
if (!presentation.visible) return null;
|
||||||
const isLoading = playbackState === 'loading';
|
|
||||||
const liveScopeActive = visible && scopeActive;
|
const isDesktop = presentation.target === 'desktop';
|
||||||
|
const isPlaying = presentation.playbackState === 'playing';
|
||||||
|
const isLoading = presentation.playbackState === 'loading';
|
||||||
|
const liveScopeActive = visible && scopeActive && !isDesktop;
|
||||||
|
|
||||||
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
|
const onLayout = (e: LayoutChangeEvent) => setPillWidth(e.nativeEvent.layout.width);
|
||||||
|
const onTogglePlay = () => {
|
||||||
|
if (isDesktop) {
|
||||||
|
if (!desktopConnection || desktopConnectionState === 'error') {
|
||||||
|
void connectDesktop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void sendDesktopControl(isPlaying ? 'pause' : 'play');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void togglePlay();
|
||||||
|
};
|
||||||
|
const onSkipNext = () => {
|
||||||
|
if (isDesktop) {
|
||||||
|
void sendDesktopControl('next');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void skipToNext();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<>
|
||||||
pointerEvents={visible ? 'auto' : 'none'}
|
<Pressable
|
||||||
style={[styles.pill, !visible && styles.hidden]}
|
pointerEvents={visible ? 'auto' : 'none'}
|
||||||
onPress={() => router.push('/now-playing')}
|
style={[styles.pill, !visible && styles.hidden]}
|
||||||
onLayout={onLayout}
|
onPress={() => router.push('/now-playing')}
|
||||||
>
|
onLayout={onLayout}
|
||||||
{liveScopeActive && pillWidth > 0 && (
|
>
|
||||||
<View pointerEvents="none" style={styles.spectrum}>
|
{liveScopeActive && pillWidth > 0 && (
|
||||||
<SpectrumCurve
|
<View pointerEvents="none" style={styles.spectrum}>
|
||||||
active={liveScopeActive}
|
<SpectrumCurve
|
||||||
pointCount={CURVE_POINTS}
|
active={liveScopeActive}
|
||||||
analysisFrameMs={0}
|
pointCount={CURVE_POINTS}
|
||||||
dbMin={-84}
|
analysisFrameMs={0}
|
||||||
dbMax={-20}
|
dbMin={-84}
|
||||||
width={pillWidth}
|
dbMax={-20}
|
||||||
height={PILL_HEIGHT}
|
width={pillWidth}
|
||||||
lineWidth={1.25}
|
height={PILL_HEIGHT}
|
||||||
lineOpacity={0.38}
|
lineWidth={1.25}
|
||||||
fillOpacity={0.3}
|
lineOpacity={0.38}
|
||||||
glow
|
fillOpacity={0.3}
|
||||||
glowOpacity={0.06}
|
glow
|
||||||
|
glowOpacity={0.06}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
|
||||||
|
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.art}>
|
||||||
|
{presentation.artworkUri ? (
|
||||||
|
<Image source={{ uri: presentation.artworkUri }} style={styles.artImage} contentFit="cover" />
|
||||||
|
) : (
|
||||||
|
<AstraLogo size={20} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<Text variant="body" numberOfLines={1} style={styles.title}>
|
||||||
|
{presentation.title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="label" numberOfLines={1}>
|
||||||
|
{presentation.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isDesktop ? (
|
||||||
|
<Pressable
|
||||||
|
hitSlop={10}
|
||||||
|
onPress={() => setTargetPickerOpen(true)}
|
||||||
|
style={styles.control}
|
||||||
|
accessibilityLabel="Choose output device"
|
||||||
|
>
|
||||||
|
<Ionicons name="desktop-outline" size={21} color={colors.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<Pressable hitSlop={10} onPress={onTogglePlay} style={styles.control}>
|
||||||
|
<Ionicons
|
||||||
|
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||||
|
size={24}
|
||||||
|
color={colors.accent}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable hitSlop={10} onPress={onSkipNext} style={styles.control}>
|
||||||
|
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{presentation.hasTrack ? (
|
||||||
|
<MiniProgress
|
||||||
|
currentTime={presentation.currentTime}
|
||||||
|
duration={presentation.duration}
|
||||||
|
isPlaying={isPlaying}
|
||||||
/>
|
/>
|
||||||
</View>
|
) : null}
|
||||||
)}
|
</Pressable>
|
||||||
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
|
<PlaybackTargetPicker
|
||||||
|
visible={targetPickerOpen}
|
||||||
<View style={styles.row}>
|
onClose={() => setTargetPickerOpen(false)}
|
||||||
<View style={styles.art}>
|
/>
|
||||||
{track.artworkData ? (
|
</>
|
||||||
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
|
|
||||||
) : (
|
|
||||||
<AstraLogo size={20} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.meta}>
|
|
||||||
<Text variant="body" numberOfLines={1} style={styles.title}>
|
|
||||||
{track.title}
|
|
||||||
</Text>
|
|
||||||
<Text variant="label" numberOfLines={1}>
|
|
||||||
{track.artist}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable hitSlop={10} onPress={togglePlay} style={styles.control}>
|
|
||||||
<Ionicons
|
|
||||||
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
|
||||||
size={24}
|
|
||||||
color={colors.accent}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable hitSlop={10} onPress={skipToNext} style={styles.control}>
|
|
||||||
<Ionicons name="play-skip-forward" size={22} color={colors.textPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />
|
|
||||||
</Pressable>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from './Text';
|
||||||
|
import { colors, radius, spacing } from '@/theme';
|
||||||
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
|
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import {
|
||||||
|
desktopConnectionLabel,
|
||||||
|
hostFromBaseUrl,
|
||||||
|
} from '@/playback/playbackTargetPresentation';
|
||||||
|
|
||||||
|
interface PlaybackTargetPickerProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||||
|
const setTarget = usePlaybackTargetStore((s) => s.setTarget);
|
||||||
|
const phoneTrack = usePlayerStore((s) => s.currentTrack);
|
||||||
|
const connection = useDesktopRemoteStore((s) => s.connection);
|
||||||
|
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||||
|
const snapshot = useDesktopRemoteStore((s) => s.snapshot);
|
||||||
|
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
|
||||||
|
const connectDesktop = useDesktopRemoteStore((s) => s.connect);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) void initDesktopRemote();
|
||||||
|
}, [visible, initDesktopRemote]);
|
||||||
|
|
||||||
|
const choose = (target: PlaybackTarget) => {
|
||||||
|
void setTarget(target);
|
||||||
|
if (target === 'desktop' && connection && connectionState !== 'connected') {
|
||||||
|
void connectDesktop();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pairDesktop = () => {
|
||||||
|
onClose();
|
||||||
|
router.push('/desktop-remote' as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopSubtitle = connection
|
||||||
|
? snapshot?.currentTrack?.title ||
|
||||||
|
snapshot?.outputDeviceLabel?.trim() ||
|
||||||
|
`${desktopConnectionLabel(connectionState)} · ${hostFromBaseUrl(connection.baseUrl)}`
|
||||||
|
: 'Pair with Astra Desktop on your LAN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { paddingBottom: insets.bottom + spacing.lg }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
<Text variant="label" color={colors.textTertiary} style={styles.eyebrow}>
|
||||||
|
OUTPUT DEVICE
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TargetRow
|
||||||
|
icon="phone-portrait-outline"
|
||||||
|
title="This phone"
|
||||||
|
subtitle={phoneTrack ? phoneTrack.title : 'Local playback'}
|
||||||
|
selected={selectedTarget === 'phone'}
|
||||||
|
onPress={() => choose('phone')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{connection ? (
|
||||||
|
<TargetRow
|
||||||
|
icon="desktop-outline"
|
||||||
|
title={connection.desktopName ?? 'Astra Desktop'}
|
||||||
|
subtitle={desktopSubtitle}
|
||||||
|
selected={selectedTarget === 'desktop'}
|
||||||
|
onPress={() => choose('desktop')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TargetRow
|
||||||
|
icon="add-circle-outline"
|
||||||
|
title="Pair Astra Desktop"
|
||||||
|
subtitle="Scan or enter a desktop pairing code"
|
||||||
|
selected={false}
|
||||||
|
onPress={pairDesktop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrap, selected && styles.iconWrapSelected]}>
|
||||||
|
<Ionicons name={icon} size={21} color={selected ? colors.accent : colors.textSecondary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowText}>
|
||||||
|
<Text variant="body" numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{selected ? (
|
||||||
|
<Ionicons name="checkmark-circle" size={22} color={colors.accent} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="ellipse-outline" size={22} color={colors.textTertiary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.58)',
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderTopLeftRadius: radius.lg,
|
||||||
|
borderTopRightRadius: radius.lg,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
handle: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
backgroundColor: colors.textTertiary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
eyebrow: {
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
minHeight: 64,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radius.pill,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.bgTertiary,
|
||||||
|
},
|
||||||
|
iconWrapSelected: {
|
||||||
|
backgroundColor: colors.glassBg,
|
||||||
|
},
|
||||||
|
rowText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PlaybackTargetPicker;
|
||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
spacing
|
spacing
|
||||||
} from '@/theme';
|
} from '@/theme';
|
||||||
import { motion } from '@/theme/motion';
|
import { motion } from '@/theme/motion';
|
||||||
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
type IconName = keyof typeof Ionicons.glyphMap;
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
|
type MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
|
||||||
@@ -54,6 +57,10 @@ export function TabBar({ items, onPress }: TabBarProps) {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const tabs = items.filter((item) => TAB_META[item.name]);
|
const tabs = items.filter((item) => TAB_META[item.name]);
|
||||||
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
|
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
|
||||||
|
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||||
|
const phoneTrack = usePlayerStore((s) => s.currentTrack);
|
||||||
|
const desktopConnection = useDesktopRemoteStore((s) => s.connection);
|
||||||
|
const desktopTrack = useDesktopRemoteStore((s) => s.snapshot?.currentTrack);
|
||||||
const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused);
|
const [settledHomeFocused, setSettledHomeFocused] = useState(homeFocused);
|
||||||
const count = tabs.length;
|
const count = tabs.length;
|
||||||
const activeIndex = Math.max(
|
const activeIndex = Math.max(
|
||||||
@@ -79,11 +86,17 @@ export function TabBar({ items, onPress }: TabBarProps) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [homeFocused, settledHomeFocused]);
|
}, [homeFocused, settledHomeFocused]);
|
||||||
|
|
||||||
const miniPlayerPhase: MiniPlayerPhase = homeFocused
|
const remoteMiniVisibleOnHome =
|
||||||
? settledHomeFocused
|
(selectedTarget === 'desktop' && Boolean(desktopConnection || desktopTrack)) ||
|
||||||
|
(!phoneTrack && Boolean(desktopTrack));
|
||||||
|
const suppressMiniForHome = homeFocused && !remoteMiniVisibleOnHome;
|
||||||
|
const suppressMiniForSettledHome = settledHomeFocused && !remoteMiniVisibleOnHome;
|
||||||
|
|
||||||
|
const miniPlayerPhase: MiniPlayerPhase = suppressMiniForHome
|
||||||
|
? suppressMiniForSettledHome
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: 'reserved'
|
: 'reserved'
|
||||||
: settledHomeFocused
|
: suppressMiniForSettledHome
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: 'visible';
|
: 'visible';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { PlaybackState, Track } from '@/types/audio';
|
||||||
|
import type {
|
||||||
|
DesktopRemoteConnection,
|
||||||
|
DesktopRemoteNowPlayingSnapshot,
|
||||||
|
} from '@/types/desktopRemote';
|
||||||
|
import type { DesktopRemoteConnectionState } from '@/stores/desktopRemoteStore';
|
||||||
|
import type { PlaybackTarget } from '@/stores/playbackTargetStore';
|
||||||
|
|
||||||
|
export interface PlaybackPresentation {
|
||||||
|
target: PlaybackTarget;
|
||||||
|
sourceLabel: string;
|
||||||
|
deviceLabel: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
artworkUri: string | null;
|
||||||
|
playbackState: PlaybackState;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
trackKey: string | null;
|
||||||
|
hasTrack: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectivePlaybackPresentation({
|
||||||
|
selectedTarget,
|
||||||
|
phone,
|
||||||
|
desktop,
|
||||||
|
}: {
|
||||||
|
selectedTarget: PlaybackTarget;
|
||||||
|
phone: PlaybackPresentation;
|
||||||
|
desktop: PlaybackPresentation;
|
||||||
|
}): PlaybackPresentation {
|
||||||
|
if (selectedTarget === 'desktop') return desktop;
|
||||||
|
if (!phone.visible && desktop.hasTrack) return desktop;
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function desktopConnectionLabel(state: DesktopRemoteConnectionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Live';
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connecting';
|
||||||
|
case 'reconnecting':
|
||||||
|
return 'Retrying';
|
||||||
|
case 'pinEntry':
|
||||||
|
return 'PIN required';
|
||||||
|
case 'pendingApproval':
|
||||||
|
return 'Waiting for approval';
|
||||||
|
case 'pairing':
|
||||||
|
return 'Pairing';
|
||||||
|
case 'error':
|
||||||
|
return 'Offline';
|
||||||
|
default:
|
||||||
|
return 'Not paired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostFromBaseUrl(baseUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(baseUrl).host;
|
||||||
|
} catch {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhonePlaybackPresentation({
|
||||||
|
track,
|
||||||
|
playbackState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
track: Track | null;
|
||||||
|
playbackState: PlaybackState;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
}): PlaybackPresentation {
|
||||||
|
return {
|
||||||
|
target: 'phone',
|
||||||
|
sourceLabel: track?.album?.trim() || 'This phone',
|
||||||
|
deviceLabel: 'This phone',
|
||||||
|
title: track?.title || 'Nothing playing',
|
||||||
|
subtitle: track?.artist || 'Start a track from Home',
|
||||||
|
artworkUri: track?.artworkData ?? null,
|
||||||
|
playbackState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
trackKey: track?.path ?? null,
|
||||||
|
hasTrack: Boolean(track),
|
||||||
|
visible: Boolean(track),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopPlaybackPresentation({
|
||||||
|
connection,
|
||||||
|
connectionState,
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
connection: DesktopRemoteConnection | null;
|
||||||
|
connectionState: DesktopRemoteConnectionState;
|
||||||
|
snapshot: DesktopRemoteNowPlayingSnapshot | null;
|
||||||
|
}): PlaybackPresentation {
|
||||||
|
const currentTrack = snapshot?.currentTrack ?? null;
|
||||||
|
const desktopName = connection?.desktopName?.trim() || 'Astra Desktop';
|
||||||
|
const status = desktopConnectionLabel(connectionState);
|
||||||
|
return {
|
||||||
|
target: 'desktop',
|
||||||
|
sourceLabel: desktopName,
|
||||||
|
deviceLabel: desktopName,
|
||||||
|
title: currentTrack?.title || desktopName,
|
||||||
|
subtitle:
|
||||||
|
currentTrack?.artist ||
|
||||||
|
currentTrack?.album ||
|
||||||
|
snapshot?.outputDeviceLabel?.trim() ||
|
||||||
|
(connection ? status : 'Pair to control desktop playback'),
|
||||||
|
artworkUri: currentTrack?.artworkDataUrl || currentTrack?.artworkUrl || null,
|
||||||
|
playbackState: snapshot?.playbackState === 'stopped' ? 'stopped' : snapshot?.playbackState ?? 'stopped',
|
||||||
|
currentTime: snapshot?.currentTime ?? 0,
|
||||||
|
duration: snapshot?.duration ?? 0,
|
||||||
|
trackKey: currentTrack?.id ?? connection?.id ?? null,
|
||||||
|
hasTrack: Boolean(currentTrack),
|
||||||
|
visible: Boolean(connection || currentTrack),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { openLibraryDb } from '@/db/database';
|
import { openLibraryDb } from '@/db/database';
|
||||||
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
|
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
|
||||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||||
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import type {
|
import type {
|
||||||
DesktopRemoteConnection,
|
DesktopRemoteConnection,
|
||||||
DesktopRemoteControlCommand,
|
DesktopRemoteControlCommand,
|
||||||
@@ -279,6 +280,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
message: 'Paired. Connecting...',
|
message: 'Paired. Connecting...',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
});
|
});
|
||||||
|
void usePlaybackTargetStore.getState().setTarget('desktop');
|
||||||
void get().connect();
|
void get().connect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -423,6 +425,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
message: 'Paired. Connecting...',
|
message: 'Paired. Connecting...',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
});
|
});
|
||||||
|
void usePlaybackTargetStore.getState().setTarget('desktop');
|
||||||
void get().connect();
|
void get().connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
|
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
|
||||||
@@ -628,6 +631,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
|
|||||||
pinPairing: null,
|
pinPairing: null,
|
||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
|
void usePlaybackTargetStore.getState().setTarget('phone');
|
||||||
},
|
},
|
||||||
|
|
||||||
sendControl: async (command, time) => {
|
sendControl: async (command, time) => {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { openLibraryDb } from '@/db/database';
|
||||||
|
import { getSetting, setSetting } from '@/db/queries';
|
||||||
|
|
||||||
|
const PLAYBACK_TARGET_KEY = 'playback_target';
|
||||||
|
|
||||||
|
export type PlaybackTarget = 'phone' | 'desktop';
|
||||||
|
|
||||||
|
function parsePlaybackTarget(value: string | null): PlaybackTarget {
|
||||||
|
return value === 'desktop' ? 'desktop' : 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaybackTargetStore {
|
||||||
|
target: PlaybackTarget;
|
||||||
|
loaded: boolean;
|
||||||
|
load: () => Promise<void>;
|
||||||
|
setTarget: (target: PlaybackTarget) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaybackTargetStore = create<PlaybackTargetStore>((set, get) => ({
|
||||||
|
target: 'phone',
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
if (get().loaded) return;
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
const stored = await getSetting(db, PLAYBACK_TARGET_KEY);
|
||||||
|
set({ target: parsePlaybackTarget(stored), loaded: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
setTarget: async (target) => {
|
||||||
|
if (get().target === target && get().loaded) return;
|
||||||
|
set({ target, loaded: true });
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await setSetting(db, PLAYBACK_TARGET_KEY, target);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user