mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
improve desktop remote
This commit is contained in:
@@ -25,6 +25,7 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||
import { 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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<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
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from './Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import {
|
||||
desktopConnectionLabel,
|
||||
hostFromBaseUrl,
|
||||
} from '@/playback/playbackTargetPresentation';
|
||||
|
||||
interface PlaybackTargetPickerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerProps) {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||
const setTarget = usePlaybackTargetStore((s) => s.setTarget);
|
||||
const phoneTrack = usePlayerStore((s) => s.currentTrack);
|
||||
const connection = useDesktopRemoteStore((s) => s.connection);
|
||||
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
|
||||
const snapshot = useDesktopRemoteStore((s) => s.snapshot);
|
||||
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
|
||||
const connectDesktop = useDesktopRemoteStore((s) => s.connect);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) void initDesktopRemote();
|
||||
}, [visible, initDesktopRemote]);
|
||||
|
||||
const choose = (target: PlaybackTarget) => {
|
||||
void setTarget(target);
|
||||
if (target === 'desktop' && connection && connectionState !== 'connected') {
|
||||
void connectDesktop();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const pairDesktop = () => {
|
||||
onClose();
|
||||
router.push('/desktop-remote' as never);
|
||||
};
|
||||
|
||||
const desktopSubtitle = connection
|
||||
? snapshot?.currentTrack?.title ||
|
||||
snapshot?.outputDeviceLabel?.trim() ||
|
||||
`${desktopConnectionLabel(connectionState)} · ${hostFromBaseUrl(connection.baseUrl)}`
|
||||
: 'Pair with Astra Desktop on your LAN';
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable
|
||||
style={[styles.sheet, { paddingBottom: insets.bottom + spacing.lg }]}
|
||||
onPress={(event) => event.stopPropagation()}
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.eyebrow}>
|
||||
OUTPUT DEVICE
|
||||
</Text>
|
||||
|
||||
<TargetRow
|
||||
icon="phone-portrait-outline"
|
||||
title="This phone"
|
||||
subtitle={phoneTrack ? phoneTrack.title : 'Local playback'}
|
||||
selected={selectedTarget === 'phone'}
|
||||
onPress={() => choose('phone')}
|
||||
/>
|
||||
|
||||
{connection ? (
|
||||
<TargetRow
|
||||
icon="desktop-outline"
|
||||
title={connection.desktopName ?? 'Astra Desktop'}
|
||||
subtitle={desktopSubtitle}
|
||||
selected={selectedTarget === 'desktop'}
|
||||
onPress={() => choose('desktop')}
|
||||
/>
|
||||
) : (
|
||||
<TargetRow
|
||||
icon="add-circle-outline"
|
||||
title="Pair Astra Desktop"
|
||||
subtitle="Scan or enter a desktop pairing code"
|
||||
selected={false}
|
||||
onPress={pairDesktop}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetRow({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={[styles.iconWrap, selected && styles.iconWrapSelected]}>
|
||||
<Ionicons name={icon} size={21} color={selected ? colors.accent : colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={22} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={22} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.58)',
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
paddingTop: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 44,
|
||||
height: 4,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.textTertiary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
eyebrow: {
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
row: {
|
||||
minHeight: 64,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.pill,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
iconWrapSelected: {
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default PlaybackTargetPicker;
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
spacing
|
||||
} 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';
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PlaybackState, Track } from '@/types/audio';
|
||||
import type {
|
||||
DesktopRemoteConnection,
|
||||
DesktopRemoteNowPlayingSnapshot,
|
||||
} from '@/types/desktopRemote';
|
||||
import type { DesktopRemoteConnectionState } from '@/stores/desktopRemoteStore';
|
||||
import type { PlaybackTarget } from '@/stores/playbackTargetStore';
|
||||
|
||||
export interface PlaybackPresentation {
|
||||
target: PlaybackTarget;
|
||||
sourceLabel: string;
|
||||
deviceLabel: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
artworkUri: string | null;
|
||||
playbackState: PlaybackState;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
trackKey: string | null;
|
||||
hasTrack: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function getEffectivePlaybackPresentation({
|
||||
selectedTarget,
|
||||
phone,
|
||||
desktop,
|
||||
}: {
|
||||
selectedTarget: PlaybackTarget;
|
||||
phone: PlaybackPresentation;
|
||||
desktop: PlaybackPresentation;
|
||||
}): PlaybackPresentation {
|
||||
if (selectedTarget === 'desktop') return desktop;
|
||||
if (!phone.visible && desktop.hasTrack) return desktop;
|
||||
return phone;
|
||||
}
|
||||
|
||||
export function desktopConnectionLabel(state: DesktopRemoteConnectionState): string {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return 'Live';
|
||||
case 'connecting':
|
||||
return 'Connecting';
|
||||
case 'reconnecting':
|
||||
return 'Retrying';
|
||||
case 'pinEntry':
|
||||
return 'PIN required';
|
||||
case 'pendingApproval':
|
||||
return 'Waiting for approval';
|
||||
case 'pairing':
|
||||
return 'Pairing';
|
||||
case 'error':
|
||||
return 'Offline';
|
||||
default:
|
||||
return 'Not paired';
|
||||
}
|
||||
}
|
||||
|
||||
export function hostFromBaseUrl(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl).host;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPhonePlaybackPresentation({
|
||||
track,
|
||||
playbackState,
|
||||
currentTime,
|
||||
duration,
|
||||
}: {
|
||||
track: Track | null;
|
||||
playbackState: PlaybackState;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): PlaybackPresentation {
|
||||
return {
|
||||
target: 'phone',
|
||||
sourceLabel: track?.album?.trim() || 'This phone',
|
||||
deviceLabel: 'This phone',
|
||||
title: track?.title || 'Nothing playing',
|
||||
subtitle: track?.artist || 'Start a track from Home',
|
||||
artworkUri: track?.artworkData ?? null,
|
||||
playbackState,
|
||||
currentTime,
|
||||
duration,
|
||||
trackKey: track?.path ?? null,
|
||||
hasTrack: Boolean(track),
|
||||
visible: Boolean(track),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDesktopPlaybackPresentation({
|
||||
connection,
|
||||
connectionState,
|
||||
snapshot,
|
||||
}: {
|
||||
connection: DesktopRemoteConnection | null;
|
||||
connectionState: DesktopRemoteConnectionState;
|
||||
snapshot: DesktopRemoteNowPlayingSnapshot | null;
|
||||
}): PlaybackPresentation {
|
||||
const currentTrack = snapshot?.currentTrack ?? null;
|
||||
const desktopName = connection?.desktopName?.trim() || 'Astra Desktop';
|
||||
const status = desktopConnectionLabel(connectionState);
|
||||
return {
|
||||
target: 'desktop',
|
||||
sourceLabel: desktopName,
|
||||
deviceLabel: desktopName,
|
||||
title: currentTrack?.title || desktopName,
|
||||
subtitle:
|
||||
currentTrack?.artist ||
|
||||
currentTrack?.album ||
|
||||
snapshot?.outputDeviceLabel?.trim() ||
|
||||
(connection ? status : 'Pair to control desktop playback'),
|
||||
artworkUri: currentTrack?.artworkDataUrl || currentTrack?.artworkUrl || null,
|
||||
playbackState: snapshot?.playbackState === 'stopped' ? 'stopped' : snapshot?.playbackState ?? 'stopped',
|
||||
currentTime: snapshot?.currentTime ?? 0,
|
||||
duration: snapshot?.duration ?? 0,
|
||||
trackKey: currentTrack?.id ?? connection?.id ?? null,
|
||||
hasTrack: Boolean(currentTrack),
|
||||
visible: Boolean(connection || currentTrack),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { 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) => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
import { openLibraryDb } from '@/db/database';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
|
||||
const PLAYBACK_TARGET_KEY = 'playback_target';
|
||||
|
||||
export type PlaybackTarget = 'phone' | 'desktop';
|
||||
|
||||
function parsePlaybackTarget(value: string | null): PlaybackTarget {
|
||||
return value === 'desktop' ? 'desktop' : 'phone';
|
||||
}
|
||||
|
||||
interface PlaybackTargetStore {
|
||||
target: PlaybackTarget;
|
||||
loaded: boolean;
|
||||
load: () => Promise<void>;
|
||||
setTarget: (target: PlaybackTarget) => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlaybackTargetStore = create<PlaybackTargetStore>((set, get) => ({
|
||||
target: 'phone',
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
if (get().loaded) return;
|
||||
const db = await openLibraryDb();
|
||||
const stored = await getSetting(db, PLAYBACK_TARGET_KEY);
|
||||
set({ target: parsePlaybackTarget(stored), loaded: true });
|
||||
},
|
||||
|
||||
setTarget: async (target) => {
|
||||
if (get().target === target && get().loaded) return;
|
||||
set({ target, loaded: true });
|
||||
const db = await openLibraryDb();
|
||||
await setSetting(db, PLAYBACK_TARGET_KEY, target);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user