diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 41a5f73..1524a3f 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -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() {
+
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() {
- PLAYING FROM
+ DESKTOP REMOTE
{remoteSource}
@@ -557,199 +540,65 @@ export default function DesktopRemoteScreen() {
- {currentTrack ? (
-
-
-
- {art ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {currentTrack.title}
-
-
- {currentTrack.artist || currentTrack.album || remoteSource}
-
-
- void sendControl('toggle-favorite')}
- accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
- accessibilityState={{ selected: currentTrack.isFavorite }}
- >
-
-
-
-
- void sendControl('seek', seconds)}
+
+
+ {art ? (
+
-
-
- void sendControl('toggle-shuffle')}
- accessibilityLabel="Shuffle"
- accessibilityState={{ selected: shuffleOn }}
- >
-
-
- void sendControl('previous')}
- hitSlop={12}
- style={styles.transportMainBtn}
- accessibilityLabel="Previous"
- >
-
-
- void sendControl(isPlaying ? 'pause' : 'play')}
- hitSlop={12}
- style={[styles.playButton, { backgroundColor: accent }]}
- accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
- >
-
-
- void sendControl('next')}
- hitSlop={12}
- style={styles.transportMainBtn}
- accessibilityLabel="Next"
- >
-
-
- void sendControl('toggle-repeat')}
- accessibilityLabel="Repeat"
- accessibilityState={{ selected: repeatMode !== 'none' }}
- >
- {repeatMode === 'one' ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {connectionLabel(connectionState)}
-
-
-
- {remoteDetail}
-
- {queueAvailable ? (
- setQueueOpen(true)}
- accessibilityLabel="Desktop queue"
- >
-
-
- ) : null}
-
-
-
-
-
+ ) : (
+
+ )}
- ) : (
-
-
-
- Nothing playing
+
+
+
+ {remoteSource}
-
- {statusText}
+
+ {currentTrack
+ ? `${currentTrack.title}${currentTrack.artist ? ` · ${currentTrack.artist}` : ''}`
+ : remoteDetail || statusText}
- )}
+
+
+
+
+ {connectionLabel(connectionState)}
+
+
+
+
+ router.replace('/now-playing' as never)}
+ >
+
+
+ Open Now Playing
+
+
+ void reconnect()}>
+
+ Reconnect
+
+
+
+
+ Forget desktop
+
+
+
+
{errorMessage ? (
@@ -788,7 +637,6 @@ export default function DesktopRemoteScreen() {
>
)}
- {queueOpen && setQueueOpen(false)} />}
);
}
@@ -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,
diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx
index 6d3f291..7ee8300 100644
--- a/src/app/now-playing.tsx
+++ b/src/app/now-playing.tsx
@@ -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(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() {
>
- dismissSheet()} hitSlop={12}>
-
-
+
+ dismissSheet()} hitSlop={12}>
+
+
+
PLAYING FROM
@@ -456,18 +509,252 @@ export default function NowPlayingScreen() {
{source}
-
-
-
+
+
+
+
+
- {track ? (
+ {isDesktopTarget ? (
+ activeTrack ? (
+
+
+
+ {activePresentation.artworkUri ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {activeTrack.title}
+
+
+ {activeTrack.artist || activeTrack.album || activePresentation.deviceLabel}
+
+
+ void sendDesktopControl('toggle-favorite')}
+ accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+ accessibilityState={{ selected: activeTrack.isFavorite }}
+ >
+
+
+
+
+ void sendDesktopControl('seek', seconds)}
+ />
+
+
+ void sendDesktopControl('toggle-shuffle')}
+ accessibilityLabel="Shuffle"
+ accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }}
+ >
+
+
+ void sendDesktopControl('previous')}
+ hitSlop={12}
+ style={styles.transportMainBtn}
+ accessibilityLabel="Previous"
+ >
+
+
+ void sendDesktopControl(isPlaying ? 'pause' : 'play')}
+ hitSlop={12}
+ style={styles.playButton}
+ accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
+ >
+
+
+ void sendDesktopControl('next')}
+ hitSlop={12}
+ style={styles.transportMainBtn}
+ accessibilityLabel="Next"
+ >
+
+
+ void sendDesktopControl('toggle-repeat')}
+ accessibilityLabel="Repeat"
+ accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }}
+ >
+ {desktopSnapshot?.repeat === 'one' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {desktopConnectionLabel(desktopConnectionState)}
+
+
+
+ {desktopSnapshot?.outputDeviceLabel?.trim() ||
+ (desktopConnection ? hostFromBaseUrl(desktopConnection.baseUrl) : '')}
+
+
+ void reconnectDesktop()}
+ accessibilityLabel="Reconnect to desktop"
+ >
+
+
+ {desktopQueue ? (
+ setQueueOpen(true)}
+ accessibilityLabel="Desktop queue"
+ >
+
+
+ ) : null}
+
+
+
+
+ ) : (
+
+
+
+ {desktopConnection ? 'Nothing playing on desktop' : 'No desktop paired'}
+
+
+ {desktopConnection
+ ? desktopConnectionLabel(desktopConnectionState)
+ : 'Pair with Astra Desktop to control it here.'}
+
+
+ desktopConnection
+ ? void reconnectDesktop()
+ : router.push('/desktop-remote' as never)
+ }
+ >
+
+ {desktopConnection ? 'Reconnect' : 'Pair desktop'}
+
+
+
+ )
+ ) : track ? (
setPlaylistActionTrack(null)}
/>
- {queueOpen && setQueueOpen(false)} />}
+ {queueOpen && (
+ isDesktopTarget ? (
+ setQueueOpen(false)} />
+ ) : (
+ setQueueOpen(false)} />
+ )
+ )}
+ setTargetPickerOpen(false)}
+ />
);
}
@@ -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,
+ },
});
diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts
index 68ae79e..b0f79b5 100644
--- a/src/audio/playbackController.ts
+++ b/src/audio/playbackController.ts
@@ -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');
+}
+
/** Fisher–Yates shuffle a copy of the array. */
function shuffleArray(items: readonly T[]): T[] {
const out = [...items];
@@ -158,6 +163,7 @@ async function playTracksInternal(
options: { allowBackgroundSetup: boolean },
): Promise {
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 {
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 {
/** M0 demo entry point: load the streamed sample queue if nothing is queued. */
export async function playSample(): Promise {
+ selectPhonePlaybackTarget();
await ensurePlayerReady();
await queueLoadSettled();
const queue = await TrackPlayer.getQueue();
@@ -224,6 +232,7 @@ export async function playSample(): Promise {
}
export async function play(): Promise {
+ 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 {
+ 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.
diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx
index df2493b..acd9df1 100644
--- a/src/components/MiniPlayer.tsx
+++ b/src/components/MiniPlayer.tsx
@@ -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 (
- router.push('/now-playing')}
- onLayout={onLayout}
- >
- {liveScopeActive && pillWidth > 0 && (
-
-
+ router.push('/now-playing')}
+ onLayout={onLayout}
+ >
+ {liveScopeActive && pillWidth > 0 && (
+
+
+
+ )}
+ {liveScopeActive && pillWidth > 0 && }
+
+
+
+ {presentation.artworkUri ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {presentation.title}
+
+
+ {presentation.subtitle}
+
+
+
+ {isDesktop ? (
+ setTargetPickerOpen(true)}
+ style={styles.control}
+ accessibilityLabel="Choose output device"
+ >
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {presentation.hasTrack ? (
+
-
- )}
- {liveScopeActive && pillWidth > 0 && }
-
-
-
- {track.artworkData ? (
-
- ) : (
-
- )}
-
-
-
-
- {track.title}
-
-
- {track.artist}
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ) : null}
+
+ setTargetPickerOpen(false)}
+ />
+ >
);
}
diff --git a/src/components/PlaybackTargetPicker.tsx b/src/components/PlaybackTargetPicker.tsx
new file mode 100644
index 0000000..09605ff
--- /dev/null
+++ b/src/components/PlaybackTargetPicker.tsx
@@ -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 (
+
+
+ event.stopPropagation()}
+ >
+
+
+ OUTPUT DEVICE
+
+
+ choose('phone')}
+ />
+
+ {connection ? (
+ choose('desktop')}
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function TargetRow({
+ icon,
+ title,
+ subtitle,
+ selected,
+ onPress,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ title: string;
+ subtitle: string;
+ selected: boolean;
+ onPress: () => void;
+}) {
+ return (
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={onPress}
+ accessibilityRole="button"
+ accessibilityState={{ selected }}
+ >
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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;
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index c28894d..f8df24c 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -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';
diff --git a/src/playback/playbackTargetPresentation.ts b/src/playback/playbackTargetPresentation.ts
new file mode 100644
index 0000000..6629117
--- /dev/null
+++ b/src/playback/playbackTargetPresentation.ts
@@ -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),
+ };
+}
diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts
index c37320a..9da18ed 100644
--- a/src/stores/desktopRemoteStore.ts
+++ b/src/stores/desktopRemoteStore.ts
@@ -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((set, get) => {
message: 'Paired. Connecting...',
errorMessage: '',
});
+ void usePlaybackTargetStore.getState().setTarget('desktop');
void get().connect();
return;
}
@@ -423,6 +425,7 @@ export const useDesktopRemoteStore = create((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((set, get) => {
pinPairing: null,
message: '',
});
+ void usePlaybackTargetStore.getState().setTarget('phone');
},
sendControl: async (command, time) => {
diff --git a/src/stores/playbackTargetStore.ts b/src/stores/playbackTargetStore.ts
new file mode 100644
index 0000000..3287c41
--- /dev/null
+++ b/src/stores/playbackTargetStore.ts
@@ -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;
+ setTarget: (target: PlaybackTarget) => Promise;
+}
+
+export const usePlaybackTargetStore = create((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);
+ },
+}));