mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
bring over relevant desktop settings
This commit is contained in:
@@ -41,6 +41,8 @@ import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanion
|
||||
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
||||
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
|
||||
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
@@ -73,6 +75,7 @@ import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
|
||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import {
|
||||
cycleRepeat,
|
||||
@@ -89,6 +92,7 @@ import {
|
||||
getPhonePlaybackPresentation,
|
||||
hostFromBaseUrl,
|
||||
} from '@/playback/playbackTargetPresentation';
|
||||
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
|
||||
|
||||
const DISMISS_DISTANCE = 140;
|
||||
const DISMISS_VELOCITY = 1000;
|
||||
@@ -127,6 +131,7 @@ export function NowPlayingOverlay() {
|
||||
const closeQueue = useCallback(() => setQueueOpen(false), []);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||
const [sleepTimerOpen, setSleepTimerOpen] = useState(false);
|
||||
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
||||
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||
@@ -152,6 +157,9 @@ export function NowPlayingOverlay() {
|
||||
const desktopQueue = useDesktopRemoteStore((s) => s.queue);
|
||||
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||
const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect);
|
||||
const sleepTimer = useSleepTimerStore((s) => s.timer);
|
||||
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
|
||||
void sleepRemainingMs;
|
||||
const phonePresentation = getPhonePlaybackPresentation({
|
||||
track,
|
||||
playbackState,
|
||||
@@ -311,6 +319,17 @@ export function NowPlayingOverlay() {
|
||||
setTargetPickerOpen(true);
|
||||
},
|
||||
});
|
||||
if (!isDesktopTarget) {
|
||||
menuItems.push({
|
||||
key: 'sleep-timer',
|
||||
label: sleepTimer ? `Sleep timer · ${formatSleepTimerStatus(sleepTimer)}` : 'Sleep timer',
|
||||
icon: 'moon-outline',
|
||||
onPress: () => {
|
||||
closeMenu();
|
||||
setSleepTimerOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!isDesktopTarget && artistName) {
|
||||
menuItems.push({
|
||||
key: 'artist',
|
||||
@@ -1351,6 +1370,12 @@ export function NowPlayingOverlay() {
|
||||
initialStep="pickPlaylist"
|
||||
onClose={() => setPlaylistActionTrack(null)}
|
||||
/>
|
||||
{sleepTimerOpen ? (
|
||||
<AppSheet onClose={() => setSleepTimerOpen(false)}>
|
||||
<AppSheetTitle title="Sleep timer" subtitle={sleepTimer ? formatSleepTimerStatus(sleepTimer) : undefined} />
|
||||
<SleepTimerControls />
|
||||
</AppSheet>
|
||||
) : null}
|
||||
{queueOpen && !hasTabletCompanion && (
|
||||
isDesktopTarget ? (
|
||||
<RemoteQueueSheet onClose={closeQueue} />
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, TextInput, View } from 'react-native';
|
||||
import { Text } from '@/components/Text';
|
||||
import { SLEEP_TIMER_PRESETS, formatSleepTimerStatus, normalizeSleepTimerMinutes } from '@/audio/sleepTimerState';
|
||||
import { supportsNativePauseAtEndOfItem } from '@/audio/trackPlayerExtensions';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
|
||||
export function SleepTimerControls() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const timer = useSleepTimerStore((s) => s.timer);
|
||||
const remainingMs = useSleepTimerStore((s) => s.remainingMs);
|
||||
const hydrate = useSleepTimerStore((s) => s.hydrate);
|
||||
const startMinutes = useSleepTimerStore((s) => s.startMinutes);
|
||||
const startEndOfTrack = useSleepTimerStore((s) => s.startEndOfTrack);
|
||||
const cancel = useSleepTimerStore((s) => s.cancel);
|
||||
const reconcile = useSleepTimerStore((s) => s.reconcile);
|
||||
const target = usePlaybackTargetStore((s) => s.target);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const [customMinutes, setCustomMinutes] = useState('');
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const available = target === 'phone' && Boolean(track);
|
||||
void remainingMs;
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timer) return;
|
||||
const interval = setInterval(() => void reconcile(), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reconcile, timer]);
|
||||
|
||||
const run = async (action: () => Promise<void>, success: string) => {
|
||||
setFeedback(null);
|
||||
try {
|
||||
await action();
|
||||
setFeedback(success);
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : 'Could not update the sleep timer.');
|
||||
}
|
||||
};
|
||||
|
||||
const startCustom = () => {
|
||||
const minutes = normalizeSleepTimerMinutes(customMinutes);
|
||||
if (minutes === null) {
|
||||
setFeedback('Enter a whole number from 1 to 720 minutes.');
|
||||
return;
|
||||
}
|
||||
void run(() => startMinutes(minutes), `Timer set for ${minutes} minutes.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.statusBlock}>
|
||||
<Text variant="body">{timer ? formatSleepTimerStatus(timer) : 'No sleep timer'}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{!available
|
||||
? target === 'desktop'
|
||||
? 'Sleep timers are available for phone playback only.'
|
||||
: 'Load a track on this phone to set a timer.'
|
||||
: timer?.mode === 'minutes'
|
||||
? 'Wall-clock time continues while playback is paused.'
|
||||
: timer?.mode === 'end-of-track'
|
||||
? 'Seeking and manual skips keep the timer armed.'
|
||||
: 'Playback pauses without clearing the queue or position.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.presets}>
|
||||
{SLEEP_TIMER_PRESETS.map((minutes) => (
|
||||
<Pressable
|
||||
key={minutes}
|
||||
disabled={!available}
|
||||
android_ripple={ripple.bounded}
|
||||
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||
onPress={() => void run(() => startMinutes(minutes), `Timer set for ${minutes} minutes.`)}
|
||||
style={({ pressed }) => [styles.preset, !available && styles.disabled, pressed && available && styles.pressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text variant="label" color={colors.textPrimary}>{minutes} min</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.customRow}>
|
||||
<TextInput
|
||||
value={customMinutes}
|
||||
onChangeText={setCustomMinutes}
|
||||
editable={available}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
placeholder="1–720"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
onSubmitEditing={startCustom}
|
||||
style={[styles.input, !available && styles.disabled]}
|
||||
accessibilityLabel="Custom sleep timer minutes"
|
||||
/>
|
||||
<Pressable
|
||||
disabled={!available}
|
||||
android_ripple={ripple.bounded}
|
||||
onPress={startCustom}
|
||||
style={({ pressed }) => [styles.action, !available && styles.disabled, pressed && available && styles.pressed]}
|
||||
>
|
||||
<Text variant="label" color={colors.accentTextStrong}>Set custom</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
disabled={!available || !supportsNativePauseAtEndOfItem()}
|
||||
android_ripple={ripple.bounded}
|
||||
onPress={() => void run(startEndOfTrack, 'Timer set for the end of the track.')}
|
||||
style={({ pressed }) => [
|
||||
styles.fullAction,
|
||||
(!available || !supportsNativePauseAtEndOfItem()) && styles.disabled,
|
||||
pressed && available && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Text variant="body">End of track</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{supportsNativePauseAtEndOfItem() ? 'Pause exactly before the next track begins.' : 'Requires the Android playback engine.'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{timer ? (
|
||||
<Pressable android_ripple={ripple.bounded} onPress={() => void run(cancel, 'Sleep timer canceled.')} style={styles.cancel}>
|
||||
<Text variant="label" color={colors.warning}>Cancel sleep timer</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{feedback ? <Text variant="caption" color={colors.textSecondary}>{feedback}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
container: { gap: spacing.md },
|
||||
statusBlock: { gap: 3 },
|
||||
presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
||||
preset: {
|
||||
flexGrow: 1, minWidth: 66, alignItems: 'center', paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm, borderWidth: 1, borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
customRow: { flexDirection: 'row', gap: spacing.sm },
|
||||
input: {
|
||||
width: 92, color: colors.textPrimary, backgroundColor: colors.bgTertiary,
|
||||
borderRadius: radius.sm, borderWidth: 1, borderColor: colors.glassBorder,
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm, fontSize: 16,
|
||||
},
|
||||
action: {
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
|
||||
borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
fullAction: {
|
||||
gap: 2, padding: spacing.md, borderRadius: radius.sm,
|
||||
borderWidth: 1, borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
cancel: { alignItems: 'center', paddingVertical: spacing.sm },
|
||||
disabled: { opacity: 0.42 },
|
||||
pressed: { opacity: 0.72 },
|
||||
}));
|
||||
Reference in New Issue
Block a user