mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
bring over relevant desktop settings
This commit is contained in:
@@ -13,6 +13,7 @@ import { useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||
import {
|
||||
getLyricsLineSeekTimeSeconds,
|
||||
getSyncedLyricsDisplayLines,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
import { LyricsLine, type LyricsLineTier } from './LyricsLine';
|
||||
import type { Track } from '@/types/audio';
|
||||
|
||||
const TRANSLATION_PRIORITY = ['en', 'ja-Latn'];
|
||||
const ANCHOR_RATIO = 0.4;
|
||||
const H_PADDING = 22;
|
||||
// The displayed active line lags the audio by a fixed pipeline delay (RNTP
|
||||
@@ -48,6 +48,11 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
const ripple = useRipple();
|
||||
const entry = useLyricsStore((s) => s.byPath[track.path]);
|
||||
const loadForTrack = useLyricsStore((s) => s.loadForTrack);
|
||||
const wordTimingEnabled = useLyricsSettingsStore((s) => s.wordTimingEnabled);
|
||||
const furiganaEnabled = useLyricsSettingsStore((s) => s.furiganaEnabled);
|
||||
const translationsEnabled = useLyricsSettingsStore((s) => s.translationsEnabled);
|
||||
const translationPriority = useLyricsSettingsStore((s) => s.translationPriority);
|
||||
const voiceLabelsEnabled = useLyricsSettingsStore((s) => s.voiceLabelsEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
void loadForTrack(track);
|
||||
@@ -109,7 +114,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
}, 90);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [track.path, displayLines.length]);
|
||||
}, [track.path, displayLines.length, furiganaEnabled, translationsEnabled, voiceLabelsEnabled, wordTimingEnabled]);
|
||||
|
||||
// Follow the active/focus line as playback advances.
|
||||
useEffect(() => {
|
||||
@@ -184,8 +189,8 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
};
|
||||
|
||||
if (displayLine.kind === 'gap') {
|
||||
const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0;
|
||||
const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral;
|
||||
const progress = isCurrentGap ? getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0 : 0;
|
||||
return (
|
||||
<View
|
||||
key={displayLine.key}
|
||||
@@ -219,7 +224,12 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
||||
line={displayLine.line}
|
||||
tier={tier}
|
||||
baseSize={baseSize}
|
||||
translationPriority={TRANSLATION_PRIORITY}
|
||||
activeTimeSeconds={isActive && wordTimingEnabled ? lyricsTime : null}
|
||||
wordTimingEnabled={wordTimingEnabled}
|
||||
furiganaEnabled={furiganaEnabled}
|
||||
translationsEnabled={translationsEnabled}
|
||||
translationPriority={translationPriority}
|
||||
voiceLabelsEnabled={voiceLabelsEnabled}
|
||||
onSeek={() => {
|
||||
if (seekSeconds != null) onSeek(seekSeconds);
|
||||
}}
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile /
|
||||
// Apple-Music reading style), with furigana ruby columns when present and an
|
||||
// optional translation line beneath. Tapping seeks to the line.
|
||||
//
|
||||
// Every line uses the SAME font size, weight, and metrics, so text wrapping and
|
||||
// line heights are identical across tiers — the layout never reflows as the active
|
||||
// line moves, which is what caused the surrounding lines to shift/flicker. The
|
||||
// active line is emphasized only by opacity, a small scale, and an accent glow —
|
||||
// all paint/transform, never layout — and both animate so tier changes ease
|
||||
// instead of snapping.
|
||||
//
|
||||
// RN has no <ruby>, so ruby is a stacked column: a small reading Text over the
|
||||
// base Text. Every segment is the identical box (a View reserving `readingHeight`
|
||||
// of top space with the base below) so all bases land on one line; the reading is
|
||||
// absolutely positioned in that top zone, base-width and shrunk to fit, so a wide
|
||||
// reading never widens the column and spreads the sentence apart.
|
||||
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, View, type LayoutChangeEvent } from 'react-native';
|
||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { useColors } from '@/theme/themed';
|
||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||
import { getPreferredLyricsTranslation } from '@/lyrics/presentation';
|
||||
import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types';
|
||||
import { getPreferredLyricsTranslation, resolveLyricsWordTiming } from '@/lyrics/presentation';
|
||||
import type { LyricsFurigana, LyricsLine as LyricsLineData, LyricsWord } from '@/lyrics/types';
|
||||
|
||||
export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant';
|
||||
|
||||
@@ -30,16 +13,19 @@ interface LyricsLineProps {
|
||||
line: LyricsLineData;
|
||||
tier: LyricsLineTier;
|
||||
baseSize: number;
|
||||
activeTimeSeconds: number | null;
|
||||
wordTimingEnabled: boolean;
|
||||
furiganaEnabled: boolean;
|
||||
translationsEnabled: boolean;
|
||||
translationPriority: string[];
|
||||
voiceLabelsEnabled: boolean;
|
||||
onSeek: () => void;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
// scale/opacity only — never anything that reflows layout. Active scale is kept
|
||||
// small enough that the grow overflows into the horizontal padding, not off-screen.
|
||||
const TIER: Record<LyricsLineTier, { scale: number; opacity: number }> = {
|
||||
active: { scale: 1.06, opacity: 1 },
|
||||
near: { scale: 1.0, opacity: 0.52 },
|
||||
near: { scale: 1, opacity: 0.52 },
|
||||
far: { scale: 0.965, opacity: 0.27 },
|
||||
distant: { scale: 0.93, opacity: 0.13 },
|
||||
};
|
||||
@@ -64,24 +50,157 @@ function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Se
|
||||
cursor = entry.end;
|
||||
}
|
||||
if (cursor < text.length) segments.push({ text: text.slice(cursor) });
|
||||
return segments;
|
||||
return segments.length > 0 ? segments : [{ text }];
|
||||
}
|
||||
|
||||
function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) {
|
||||
function RubyText({
|
||||
text,
|
||||
furigana,
|
||||
enabled,
|
||||
size,
|
||||
lineHeight,
|
||||
readingSize,
|
||||
readingHeight,
|
||||
color,
|
||||
readingColor,
|
||||
shadow,
|
||||
}: {
|
||||
text: string;
|
||||
furigana?: LyricsFurigana[];
|
||||
enabled: boolean;
|
||||
size: number;
|
||||
lineHeight: number;
|
||||
readingSize: number;
|
||||
readingHeight: number;
|
||||
color: string;
|
||||
readingColor: string;
|
||||
shadow?: object;
|
||||
}) {
|
||||
const segments = useMemo(
|
||||
() => buildSegments(text, enabled ? furigana : undefined),
|
||||
[enabled, furigana, text]
|
||||
);
|
||||
if (!enabled || !furigana?.length) {
|
||||
return <Text variant="heading" color={color} style={{ fontSize: size, lineHeight, ...shadow }}>{text}</Text>;
|
||||
}
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{segments.map((segment, index) => (
|
||||
<View key={`${index}:${segment.text}`} style={{ paddingTop: readingHeight }}>
|
||||
<Text variant="heading" color={color} style={{ fontSize: size, lineHeight, ...shadow }}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
{segment.reading ? (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={readingColor}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0,
|
||||
height: readingHeight, fontSize: readingSize,
|
||||
lineHeight: readingHeight, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{segment.reading}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TimedWord({
|
||||
word,
|
||||
progress,
|
||||
furiganaEnabled,
|
||||
size,
|
||||
lineHeight,
|
||||
readingSize,
|
||||
readingHeight,
|
||||
}: {
|
||||
word: LyricsWord;
|
||||
progress: number;
|
||||
furiganaEnabled: boolean;
|
||||
size: number;
|
||||
lineHeight: number;
|
||||
readingSize: number;
|
||||
readingHeight: number;
|
||||
}) {
|
||||
const colors = useColors();
|
||||
const [width, setWidth] = useState(0);
|
||||
const hasFurigana = furiganaEnabled && Boolean(word.furigana?.length);
|
||||
const textTop = hasFurigana ? readingHeight : 0;
|
||||
return (
|
||||
<View
|
||||
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
<RubyText
|
||||
text={word.text}
|
||||
furigana={word.furigana}
|
||||
enabled={furiganaEnabled}
|
||||
size={size}
|
||||
lineHeight={lineHeight}
|
||||
readingSize={readingSize}
|
||||
readingHeight={readingHeight}
|
||||
color={colors.textPrimary}
|
||||
readingColor={colors.textSecondary}
|
||||
/>
|
||||
{width > 0 && progress > 0 ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: textTop,
|
||||
width: width * progress, height: lineHeight, overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="heading"
|
||||
color={colors.accentTextStrong}
|
||||
numberOfLines={1}
|
||||
style={{ width, fontSize: size, lineHeight }}
|
||||
>
|
||||
{word.text}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LyricsLineComponent({
|
||||
line,
|
||||
tier,
|
||||
baseSize,
|
||||
activeTimeSeconds,
|
||||
wordTimingEnabled,
|
||||
furiganaEnabled,
|
||||
translationsEnabled,
|
||||
translationPriority,
|
||||
voiceLabelsEnabled,
|
||||
onSeek,
|
||||
onLayout,
|
||||
}: LyricsLineProps) {
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const target = TIER[tier];
|
||||
// Uniform metrics for every line (the whole point — no wrap/reflow between tiers).
|
||||
const size = baseSize;
|
||||
const lineHeight = Math.round(size * 1.2);
|
||||
const readingSize = Math.max(9, Math.round(size * 0.5));
|
||||
const readingHeight = Math.round(readingSize * 1.25);
|
||||
const translation = useMemo(
|
||||
() => getPreferredLyricsTranslation(line, translationPriority),
|
||||
[line, translationPriority]
|
||||
() => translationsEnabled ? getPreferredLyricsTranslation(line, translationPriority) : null,
|
||||
[line, translationPriority, translationsEnabled]
|
||||
);
|
||||
const words = useMemo(
|
||||
() => wordTimingEnabled ? line.words ?? [] : [],
|
||||
[line.words, wordTimingEnabled]
|
||||
);
|
||||
const wordTiming = useMemo(
|
||||
() => activeTimeSeconds === null ? null : resolveLyricsWordTiming(words, activeTimeSeconds),
|
||||
[activeTimeSeconds, words]
|
||||
);
|
||||
const hasFurigana = Boolean(line.furigana && line.furigana.length > 0);
|
||||
const segments = useMemo(() => buildSegments(line.text, line.furigana), [line.text, line.furigana]);
|
||||
|
||||
const opacity = useSharedValue(target.opacity);
|
||||
const scale = useSharedValue(target.scale);
|
||||
@@ -93,71 +212,57 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const textColor = colors.textPrimary;
|
||||
const mainShadow =
|
||||
tier === 'active'
|
||||
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
|
||||
: undefined;
|
||||
const mainShadow = tier === 'active'
|
||||
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
|
||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onSeek} style={{ paddingVertical: 7 }}>
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
{hasFurigana ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<View key={index} style={{ paddingTop: readingHeight }}>
|
||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, ...mainShadow }}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
{segment.reading ? (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.textSecondary}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: readingHeight,
|
||||
fontSize: readingSize,
|
||||
lineHeight: readingHeight,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{segment.reading}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, textAlign: 'left', ...mainShadow }}>
|
||||
{line.text}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 5 }}>
|
||||
{voiceLabelsEnabled && line.voice?.trim() ? (
|
||||
<View style={{ borderWidth: 1, borderColor: colors.accent, borderRadius: 999, paddingHorizontal: 5, paddingVertical: 1 }}>
|
||||
<Text variant="caption" color={colors.accentTextStrong} style={{ fontSize: Math.max(9, Math.round(size * 0.45)), textTransform: 'uppercase' }}>
|
||||
{line.voice.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{words.length > 0 ? (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{words.map((word, index) => (
|
||||
<TimedWord
|
||||
key={`${word.timestampMs}:${index}`}
|
||||
word={word}
|
||||
progress={wordTiming?.progressByIndex[index] ?? 0}
|
||||
furiganaEnabled={furiganaEnabled}
|
||||
size={size}
|
||||
lineHeight={lineHeight}
|
||||
readingSize={readingSize}
|
||||
readingHeight={readingHeight}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<RubyText
|
||||
text={line.text}
|
||||
furigana={line.furigana}
|
||||
enabled={furiganaEnabled}
|
||||
size={size}
|
||||
lineHeight={lineHeight}
|
||||
readingSize={readingSize}
|
||||
readingHeight={readingHeight}
|
||||
color={colors.textPrimary}
|
||||
readingColor={colors.textSecondary}
|
||||
shadow={mainShadow}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{translation ? (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.textSecondary}
|
||||
style={{
|
||||
fontSize: Math.max(11, Math.round(size * 0.52)),
|
||||
lineHeight: Math.round(size * 0.66),
|
||||
textAlign: 'left',
|
||||
marginTop: 3,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
style={{ fontSize: Math.max(11, Math.round(size * 0.52)), lineHeight: Math.round(size * 0.66), marginTop: 3, opacity: 0.85 }}
|
||||
>
|
||||
{translation.text}
|
||||
</Text>
|
||||
@@ -168,4 +273,16 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek
|
||||
);
|
||||
}
|
||||
|
||||
export const LyricsLine = memo(LyricsLineComponent);
|
||||
function sameLyricsLineProps(previous: LyricsLineProps, next: LyricsLineProps): boolean {
|
||||
return previous.line === next.line
|
||||
&& previous.tier === next.tier
|
||||
&& previous.baseSize === next.baseSize
|
||||
&& previous.activeTimeSeconds === next.activeTimeSeconds
|
||||
&& previous.wordTimingEnabled === next.wordTimingEnabled
|
||||
&& previous.furiganaEnabled === next.furiganaEnabled
|
||||
&& previous.translationsEnabled === next.translationsEnabled
|
||||
&& previous.translationPriority === next.translationPriority
|
||||
&& previous.voiceLabelsEnabled === next.voiceLabelsEnabled;
|
||||
}
|
||||
|
||||
export const LyricsLine = memo(LyricsLineComponent, sameLyricsLineProps);
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
DbTrack
|
||||
} from '@/types/library';
|
||||
import type { Playlist } from '@/types/playlist';
|
||||
import { SETTINGS_SEARCH_ROUTES } from '@/components/search/settingsSearchRoutes';
|
||||
|
||||
type IconName = keyof typeof Ionicons.glyphMap;
|
||||
type RouteHref =
|
||||
@@ -60,8 +61,11 @@ type RouteHref =
|
||||
| '/settings/appearance'
|
||||
| '/settings/library'
|
||||
| '/settings/audio'
|
||||
| '/settings/playback'
|
||||
| '/settings/services'
|
||||
| '/settings/lyrics'
|
||||
| '/settings/experimental'
|
||||
| '/settings/troubleshooting'
|
||||
| '/settings/info'
|
||||
| '/sources'
|
||||
| '/lastfm';
|
||||
@@ -149,6 +153,14 @@ const SETTING_ENTRIES: {
|
||||
icon: 'volume-high',
|
||||
keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
|
||||
},
|
||||
{
|
||||
id: 'setting:playback',
|
||||
label: 'Playback settings',
|
||||
subtitle: 'Sleep timer / end of track',
|
||||
href: SETTINGS_SEARCH_ROUTES.playback,
|
||||
icon: 'play-circle-outline',
|
||||
keywords: ['playback', 'sleep timer', 'timer', 'end of track', 'pause after track'],
|
||||
},
|
||||
{
|
||||
id: 'setting:library',
|
||||
label: 'Library settings',
|
||||
@@ -173,6 +185,14 @@ const SETTING_ENTRIES: {
|
||||
icon: 'server-outline',
|
||||
keywords: ['services', 'integrations', 'remote sources', 'scrobbling'],
|
||||
},
|
||||
{
|
||||
id: 'setting:lyrics',
|
||||
label: 'Lyrics settings',
|
||||
subtitle: 'XLRC / furigana / translations',
|
||||
href: SETTINGS_SEARCH_ROUTES.lyrics,
|
||||
icon: 'musical-notes-outline',
|
||||
keywords: ['lyrics', 'xlrc', 'word timing', 'furigana', 'translations', 'voice labels', 'lrclib', 'xlrcdb'],
|
||||
},
|
||||
{
|
||||
id: 'setting:sources',
|
||||
label: 'Remote sources',
|
||||
@@ -205,6 +225,14 @@ const SETTING_ENTRIES: {
|
||||
icon: 'information-circle-outline',
|
||||
keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'],
|
||||
},
|
||||
{
|
||||
id: 'setting:troubleshooting',
|
||||
label: 'Troubleshooting',
|
||||
subtitle: 'Rescan / rebuild / clear caches',
|
||||
href: SETTINGS_SEARCH_ROUTES.troubleshooting,
|
||||
icon: 'build-outline',
|
||||
keywords: ['troubleshooting', 'support', 'rescan', 'rebuild index', 'clear lyrics cache', 'clear waveform cache', 'onboarding'],
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SETTINGS_SEARCH_ROUTES } from './settingsSearchRoutes.ts';
|
||||
|
||||
test('Quick Search exposes the new stable settings destinations', () => {
|
||||
assert.deepEqual(Object.values(SETTINGS_SEARCH_ROUTES), [
|
||||
'/settings/playback',
|
||||
'/settings/lyrics',
|
||||
'/settings/troubleshooting',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const SETTINGS_SEARCH_ROUTES = {
|
||||
playback: '/settings/playback',
|
||||
lyrics: '/settings/lyrics',
|
||||
troubleshooting: '/settings/troubleshooting',
|
||||
} as const;
|
||||
Reference in New Issue
Block a user