mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user