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
+14 -4
View File
@@ -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);
}}
+204 -87
View File
@@ -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="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 },
}));
@@ -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;