bring over relevant desktop settings

This commit is contained in:
Boof2015
2026-07-14 01:14:16 -04:00
parent c9d065e662
commit 7b35e01fe0
36 changed files with 1646 additions and 126 deletions
@@ -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="1720"
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 },
}));