improve desktop remote

This commit is contained in:
Boof2015
2026-07-06 19:09:54 -04:00
parent 2b19f51a49
commit 995e00e0e7
10 changed files with 990 additions and 293 deletions
+19
View File
@@ -25,6 +25,7 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
import {
@@ -75,6 +76,23 @@ function LastFmScrobbler() {
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. */
function DesktopRemoteMediaSessionSync() {
const connection = useDesktopRemoteStore((s) => s.connection);
@@ -290,6 +308,7 @@ export default function RootLayout() {
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
<Stack
+92 -208
View File
@@ -16,15 +16,12 @@ import {
View
} from 'react-native';
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
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,
@@ -59,11 +56,8 @@ const MEDIA_BOTTOM_GAP = spacing.xl;
const TRACK_INFO_ESTIMATE = 96;
const SEEK_BLOCK_ESTIMATE = 54;
const PLAY_BUTTON_SIZE = 68;
const SKIP_ICON_SIZE = 32;
const PLAY_ICON_SIZE = 34;
const TRANSPORT_TOP_MARGIN = spacing.lg;
const SUB_BUTTON_SIZE = 40;
const SUB_ICON_SIZE = 20;
const SUB_TOP_MARGIN = spacing.lg;
const MIN_FLOATING_SPACE = spacing.sm;
@@ -246,15 +240,12 @@ export default function DesktopRemoteScreen() {
const pairManual = useDesktopRemoteStore((s) => s.pairManual);
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();
@@ -281,21 +272,13 @@ export default function DesktopRemoteScreen() {
}, [pinPairing]);
const currentTrack = snapshot?.currentTrack ?? null;
const isPlaying = snapshot?.playbackState === 'playing';
const isBusy = connectionState === 'pairing' || connectionState === 'pendingApproval' || connectionState === 'connecting';
const art = currentTrack?.artworkDataUrl ?? null;
const accent = snapshot?.visualizerLineColor || colors.accent;
const availableHeight = windowHeight - insets.top - insets.bottom;
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) : '';
@@ -541,7 +524,7 @@ export default function DesktopRemoteScreen() {
</Pressable>
<View style={styles.headerMid}>
<Text variant="caption" style={styles.eyebrow}>
PLAYING FROM
DESKTOP REMOTE
</Text>
<Text variant="label" numberOfLines={1} style={styles.source}>
{remoteSource}
@@ -557,199 +540,65 @@ export default function DesktopRemoteScreen() {
</Pressable>
</View>
{currentTrack ? (
<View style={[styles.remotePlayer, remoteLayout.isWide && styles.remotePlayerWide]}>
<View
style={[
styles.middleStack,
remoteLayout.isWide
? { width: remoteLayout.leftPaneWidth, justifyContent: 'center' }
: {
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.managePanel}>
<View style={styles.manageArt}>
{art ? (
<Image
key={currentTrack?.id}
source={{ uri: art }}
style={styles.artImage}
contentFit="cover"
/>
<View style={[styles.transport, { marginTop: remoteLayout.controlsGap }]}>
<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>
) : (
<AstraLogo size={52} />
)}
</View>
) : (
<View style={styles.remoteEmpty}>
<AstraLogo size={72} />
<Text variant="heading" style={styles.emptyTitle}>
Nothing playing
<View style={styles.manageText}>
<Text variant="heading" numberOfLines={1} style={styles.emptyTitle}>
{remoteSource}
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
{statusText}
<Text variant="body" color={colors.textSecondary} numberOfLines={2} style={styles.centered}>
{currentTrack
? `${currentTrack.title}${currentTrack.artist ? ` · ${currentTrack.artist}` : ''}`
: remoteDetail || statusText}
</Text>
</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>
{errorMessage ? (
@@ -788,7 +637,6 @@ export default function DesktopRemoteScreen() {
</>
)}
</KeyboardAvoidingView>
{queueOpen && <RemoteQueueSheet onClose={() => setQueueOpen(false)} />}
</Screen>
);
}
@@ -869,6 +717,18 @@ const styles = StyleSheet.create({
flexDirection: 'row',
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: {
opacity: 0.55,
},
@@ -970,6 +830,30 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
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: {
height: 30,
borderRadius: radius.pill,
+359 -21
View File
@@ -23,10 +23,13 @@ import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
import { MarqueeText } from '@/components/MarqueeText';
import { SeekBar } from '@/components/SeekBar';
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import {
colors,
radius,
@@ -36,8 +39,10 @@ import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveNavigationArtist } from '@/library/artistGrouping';
import { useLibraryStore } from '@/stores/libraryStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
import {
@@ -48,6 +53,13 @@ import {
togglePlay,
toggleShuffle
} from '@/audio/playbackController';
import {
desktopConnectionLabel,
getDesktopPlaybackPresentation,
getEffectivePlaybackPresentation,
getPhonePlaybackPresentation,
hostFromBaseUrl,
} from '@/playback/playbackTargetPresentation';
const DISMISS_DISTANCE = 140;
const DISMISS_VELOCITY = 1000;
@@ -257,7 +269,9 @@ export default function NowPlayingScreen() {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [queueOpen, setQueueOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const scopeMode = useSettingsStore((s) => s.scopeMode);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
@@ -271,13 +285,41 @@ export default function NowPlayingScreen() {
const repeat = usePlayerStore((s) => s.repeat);
const isFavorite = usePlaylistStore((s) => (track ? s.favoritePaths.has(track.path) : false));
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 isLoading = playbackState === 'loading';
const phonePresentation = getPhonePlaybackPresentation({
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 effectiveWidth = windowWidth - insets.left - insets.right;
const layout = getNowPlayingLayout(effectiveWidth, availableHeight, scopeStageVisible);
const source = track?.album?.trim() ? track.album : 'Library';
const layout = getNowPlayingLayout(
effectiveWidth,
availableHeight,
isDesktopTarget ? false : scopeStageVisible
);
const source = activePresentation.sourceLabel;
const shellRight =
insets.right +
layout.contentPadding +
@@ -312,7 +354,16 @@ export default function NowPlayingScreen() {
};
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({
key: 'artist',
label: 'View artist',
@@ -323,7 +374,7 @@ export default function NowPlayingScreen() {
},
});
}
if (albumKey) {
if (!isDesktopTarget && albumKey) {
menuItems.push({
key: 'album',
label: 'View album',
@@ -334,7 +385,7 @@ export default function NowPlayingScreen() {
},
});
}
if (libraryTrack) {
if (!isDesktopTarget && libraryTrack) {
menuItems.push({
key: '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.header}>
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable>
<View style={styles.headerSide}>
<Pressable style={styles.headerBtn} onPress={() => dismissSheet()} hitSlop={12}>
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.headerMid}>
<Text variant="caption" style={styles.eyebrow}>
PLAYING FROM
@@ -456,18 +509,252 @@ export default function NowPlayingScreen() {
{source}
</Text>
</View>
<Pressable
style={styles.headerBtn}
onPress={openMenu}
disabled={menuItems.length === 0}
hitSlop={12}
accessibilityLabel="More options"
>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
<View style={[styles.headerSide, styles.headerActions]}>
<Pressable
style={styles.headerBtn}
onPress={openMenu}
hitSlop={12}
accessibilityLabel="More options"
>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
</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={[
@@ -757,7 +1044,17 @@ export default function NowPlayingScreen() {
initialStep="pickPlaylist"
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>
);
}
@@ -787,6 +1084,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
headerSide: {
width: 48,
flexDirection: 'row',
alignItems: 'center',
},
headerActions: {
justifyContent: 'flex-end',
},
headerMid: {
flex: 1,
alignItems: 'center',
@@ -952,6 +1257,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
controlDisabled: {
opacity: 0.35,
},
playButton: {
width: PLAY_BUTTON_SIZE,
height: PLAY_BUTTON_SIZE,
@@ -981,6 +1289,24 @@ const styles = StyleSheet.create({
flexShrink: 0,
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: {
width: SUB_BUTTON_SIZE,
height: SUB_BUTTON_SIZE,
@@ -992,4 +1318,16 @@ const styles = StyleSheet.create({
alignItems: '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,
},
});
+10
View File
@@ -6,6 +6,7 @@ import TrackPlayer, {
import type { PlaybackState, Track } from '@/types/audio';
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, rntpToTrack, toRntpTrack } from './sampleTracks';
import {
@@ -120,6 +121,10 @@ function syncOriginalOrderFromMirrorIfUnshuffled(): void {
if (hasSnapshot) originalOrder = tracks.map(rntpTrackId);
}
function selectPhonePlaybackTarget(): void {
void usePlaybackTargetStore.getState().setTarget('phone');
}
/** FisherYates shuffle a copy of the array. */
function shuffleArray<T>(items: readonly T[]): T[] {
const out = [...items];
@@ -158,6 +163,7 @@ async function playTracksInternal(
options: { allowBackgroundSetup: boolean },
): Promise<void> {
if (tracks.length === 0) return;
selectPhonePlaybackTarget();
await ensurePlayerReady(options);
originalOrder = tracks.map((t) => t.id);
// 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). */
export async function shuffleTracks(tracks: Track[]): Promise<void> {
if (tracks.length === 0) return;
selectPhonePlaybackTarget();
await ensurePlayerReady();
originalOrder = tracks.map((t) => t.id);
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. */
export async function playSample(): Promise<void> {
selectPhonePlaybackTarget();
await ensurePlayerReady();
await queueLoadSettled();
const queue = await TrackPlayer.getQueue();
@@ -224,6 +232,7 @@ export async function playSample(): Promise<void> {
}
export async function play(): Promise<void> {
selectPhonePlaybackTarget();
usePlayerStore.getState().setPlaybackState('playing');
try {
await TrackPlayer.play();
@@ -453,6 +462,7 @@ export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex:
/** Jump to (and play) an absolute queue index. */
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
// a shifted native index while the head is still prepending) — translate,
// waiting out the fill only when the target isn't loaded.
+134 -61
View File
@@ -17,9 +17,17 @@ import {
spacing
} from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
import {
getDesktopPlaybackPresentation,
getEffectivePlaybackPresentation,
getPhonePlaybackPresentation,
} from '@/playback/playbackTargetPresentation';
const PILL_HEIGHT = 56;
const ART = 42;
@@ -54,81 +62,146 @@ function MiniProgress({
*/
export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const router = useRouter();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
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 [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';
const isLoading = playbackState === 'loading';
const liveScopeActive = visible && scopeActive;
if (!presentation.visible) return null;
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 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 (
<Pressable
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')}
onLayout={onLayout}
>
{liveScopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.25}
lineOpacity={0.38}
fillOpacity={0.3}
glow
glowOpacity={0.06}
<>
<Pressable
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.pill, !visible && styles.hidden]}
onPress={() => router.push('/now-playing')}
onLayout={onLayout}
>
{liveScopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
active={liveScopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.25}
lineOpacity={0.38}
fillOpacity={0.3}
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>
)}
{liveScopeActive && pillWidth > 0 && <View pointerEvents="none" style={styles.spectrumVeil} />}
<View style={styles.row}>
<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>
) : null}
</Pressable>
<PlaybackTargetPicker
visible={targetPickerOpen}
onClose={() => setTargetPickerOpen(false)}
/>
</>
);
}
+195
View File
@@ -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;
+16 -3
View File
@@ -21,6 +21,9 @@ import {
spacing
} from '@/theme';
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 MiniPlayerPhase = 'hidden' | 'reserved' | 'visible';
@@ -54,6 +57,10 @@ export function TabBar({ items, onPress }: TabBarProps) {
const insets = useSafeAreaInsets();
const tabs = items.filter((item) => TAB_META[item.name]);
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 count = tabs.length;
const activeIndex = Math.max(
@@ -79,11 +86,17 @@ export function TabBar({ items, onPress }: TabBarProps) {
return () => clearTimeout(timer);
}, [homeFocused, settledHomeFocused]);
const miniPlayerPhase: MiniPlayerPhase = homeFocused
? settledHomeFocused
const remoteMiniVisibleOnHome =
(selectedTarget === 'desktop' && Boolean(desktopConnection || desktopTrack)) ||
(!phoneTrack && Boolean(desktopTrack));
const suppressMiniForHome = homeFocused && !remoteMiniVisibleOnHome;
const suppressMiniForSettledHome = settledHomeFocused && !remoteMiniVisibleOnHome;
const miniPlayerPhase: MiniPlayerPhase = suppressMiniForHome
? suppressMiniForSettledHome
? 'hidden'
: 'reserved'
: settledHomeFocused
: suppressMiniForSettledHome
? 'hidden'
: 'visible';
+124
View File
@@ -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),
};
}
+4
View File
@@ -30,6 +30,7 @@ import {
import { openLibraryDb } from '@/db/database';
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import type {
DesktopRemoteConnection,
DesktopRemoteControlCommand,
@@ -279,6 +280,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
message: 'Paired. Connecting...',
errorMessage: '',
});
void usePlaybackTargetStore.getState().setTarget('desktop');
void get().connect();
return;
}
@@ -423,6 +425,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
message: 'Paired. Connecting...',
errorMessage: '',
});
void usePlaybackTargetStore.getState().setTarget('desktop');
void get().connect();
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
@@ -628,6 +631,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
pinPairing: null,
message: '',
});
void usePlaybackTargetStore.getState().setTarget('phone');
},
sendControl: async (command, time) => {
+37
View File
@@ -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);
},
}));