From 995e00e0e7f8ad1787e9aebe66d02b9fabbc162c Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:09:54 -0400 Subject: [PATCH] improve desktop remote --- src/app/_layout.tsx | 19 ++ src/app/desktop-remote.tsx | 300 +++++----------- src/app/now-playing.tsx | 380 +++++++++++++++++++-- src/audio/playbackController.ts | 10 + src/components/MiniPlayer.tsx | 195 +++++++---- src/components/PlaybackTargetPicker.tsx | 195 +++++++++++ src/components/TabBar.tsx | 19 +- src/playback/playbackTargetPresentation.ts | 124 +++++++ src/stores/desktopRemoteStore.ts | 4 + src/stores/playbackTargetStore.ts | 37 ++ 10 files changed, 990 insertions(+), 293 deletions(-) create mode 100644 src/components/PlaybackTargetPicker.tsx create mode 100644 src/playback/playbackTargetPresentation.ts create mode 100644 src/stores/playbackTargetStore.ts 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); + }, +}));