This commit is contained in:
Boof2015
2026-06-19 13:19:37 -04:00
parent 978d001db9
commit bc687dfe87
145 changed files with 4685 additions and 197 deletions
+355 -50
View File
@@ -1,76 +1,381 @@
import { View, StyleSheet } from 'react-native';
import { useCallback, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import { readAsStringAsync } from 'expo-file-system/legacy';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQGraph } from '@/components/eq/EQGraph';
import { BandStrip } from '@/components/eq/BandStrip';
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
import { EQSlider } from '@/components/eq/EQSlider';
import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import { colors, radius, spacing } from '@/theme';
import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore';
import { setActivePostEqNative } from '@/audio/eqNative';
import {
EQ_MAX_BANDS,
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MAX_PREAMP_DB,
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType,
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
import type { EQBand, EQBandType } from '@/types/audio';
function formatFreq(hz: number): string {
return hz >= 1000 ? `${hz / 1000}k` : `${hz}`;
}
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
export default function EQScreen() {
const bands = useEQStore((s) => s.bands);
const eq = useEQStore();
const scopeActive = useScopeActive();
const [focused, setFocused] = useState(false);
const [sheet, setSheet] = useState<SheetKind>('none');
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
const closeSheet = useCallback(() => setSheet('none'), []);
// Gate the post-EQ tap to while this screen is visible.
useFocusEffect(
useCallback(() => {
setFocused(true);
setActivePostEqNative(true);
return () => {
setFocused(false);
setActivePostEqNative(false);
};
}, [])
);
const activeBand = eq.bands.find((b) => b.id === eq.activeBandId) ?? null;
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
const handleImportAutoEQ = async () => {
closeSheet();
try {
const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
if (res.canceled || !res.assets?.[0]) return;
const asset = res.assets[0];
const content = await readAsStringAsync(asset.uri);
const preset = parseAutoEQ(content, asset.name);
if (preset.bands.length > 0) eq.importPreset(preset);
} catch {
/* invalid file — ignore */
}
};
return (
<Screen>
<Text variant="title" style={styles.heading}>
Equalizer
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.note}>
The band model is in place. The Media3 biquad chain that makes these
sliders live arrives in M4.
</Text>
<View style={styles.bands}>
{bands.map((band) => (
<View key={band.id} style={styles.band}>
<View style={styles.track}>
<View style={styles.knob} />
</View>
<Text variant="caption" style={styles.freq}>
{formatFreq(band.frequency)}
</Text>
</View>
))}
<Screen padded={false}>
<View style={styles.header}>
<Text variant="heading">Equalizer</Text>
<View style={styles.headerActions}>
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
<Ionicons name="save-outline" size={20} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}>
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
</Pressable>
</View>
</View>
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
<Text variant="body" color={colors.textPrimary}>
{presetName}
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</Pressable>
<View style={styles.graphWrap}>
<EQGraph
bands={eq.bands}
activeBandId={eq.activeBandId}
enabled={eq.enabled}
spectrumActive={scopeActive && focused}
onSelectBand={eq.selectBand}
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
/>
</View>
<View style={styles.section}>
<BandStrip
bands={eq.bands}
activeBandId={eq.activeBandId}
canAdd={eq.bands.length < EQ_MAX_BANDS}
onSelect={eq.selectBand}
onAdd={() => eq.addBand()}
/>
</View>
<View style={styles.section}>
<BandDetailPanel
band={activeBand}
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
onEditType={() => setSheet('type')}
onEditValue={setEditingValue}
/>
</View>
<View style={styles.bottomBar}>
<View style={styles.preamp}>
<EQSlider
label="Preamp"
value={eq.preamp}
min={EQ_MIN_PREAMP_DB}
max={EQ_MAX_PREAMP_DB}
format={(v) => `${formatGain(v)} dB`}
onChange={eq.setPreamp}
/>
</View>
<Pressable
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
onPress={eq.toggleEnabled}
>
<Ionicons
name="power"
size={16}
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
/>
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
{eq.enabled ? 'EQ on' : 'EQ off'}
</Text>
</Pressable>
</View>
{sheet === 'preset' ? (
<PresetSheet
presets={eq.presets}
activePresetId={eq.activePresetId}
onApply={eq.applyPreset}
onDelete={eq.deleteCustomPreset}
onSaveNew={() => setSheet('save')}
onClose={closeSheet}
/>
) : null}
{sheet === 'save' ? (
<SavePresetSheet
defaultName={defaultPresetName}
onSave={(name) => eq.saveCustomPreset(name)}
onClose={closeSheet}
/>
) : null}
{sheet === 'overflow' ? (
<EqSheet onClose={closeSheet}>
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
{eq.bands.length > 1 && activeBand ? (
<EqSheetItem
label={`Remove band ${activeBandNumber}`}
icon="remove-circle-outline"
onPress={() => {
eq.removeBand(activeBand.id);
closeSheet();
}}
/>
) : null}
<EqSheetItem
label="Reset to Flat"
icon="refresh-outline"
destructive
onPress={() => {
eq.resetToFlat();
closeSheet();
}}
/>
</EqSheet>
) : null}
{sheet === 'type' && activeBand ? (
<EqSheet onClose={closeSheet}>
<Text variant="heading" style={styles.sheetTitle}>
Filter type
</Text>
{BAND_TYPES.map((type) => (
<EqSheetItem
key={type}
label={BAND_TYPE_LABEL[type]}
selected={type === activeBand.type}
onPress={() => {
eq.updateBand(activeBand.id, { type });
closeSheet();
}}
/>
))}
</EqSheet>
) : null}
{valueEditConfig && activeBand && editingValue ? (
<EQValueEditSheet
title={valueEditConfig.title}
initialValue={valueEditConfig.initialValue}
unit={valueEditConfig.unit}
rangeLabel={valueEditConfig.rangeLabel}
placeholder={valueEditConfig.placeholder}
keyboardType={valueEditConfig.keyboardType}
parseValue={valueEditConfig.parseValue}
onApply={(value) => eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))}
onClose={() => setEditingValue(null)}
/>
) : null}
</Screen>
);
}
function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
switch (kind) {
case 'frequency':
return {
title: 'Edit frequency',
initialValue: String(Math.round(band.frequency)),
unit: 'Hz',
rangeLabel: `${EQ_MIN_FREQUENCY}-${EQ_MAX_FREQUENCY} Hz`,
placeholder: '1000 or 1k',
keyboardType: 'default' as const,
parseValue: parseFrequency,
};
case 'gain':
if (isPassEQBandType(band.type)) return null;
return {
title: 'Edit gain',
initialValue: band.gain.toFixed(1),
unit: 'dB',
rangeLabel: `${-EQ_MAX_GAIN_DB} to +${EQ_MAX_GAIN_DB} dB`,
placeholder: '0.0',
keyboardType: 'numbers-and-punctuation' as const,
parseValue: parseDb,
};
case 'Q':
return {
title: 'Edit Q',
initialValue: band.Q.toFixed(2),
unit: 'Q',
rangeLabel: `${EQ_MIN_Q}-${EQ_MAX_Q}`,
placeholder: '1.00',
keyboardType: 'numbers-and-punctuation' as const,
parseValue: parsePlainNumber,
};
}
}
function createValueUpdate(kind: EQEditableValue, value: number): Partial<EQBand> {
switch (kind) {
case 'frequency':
return { frequency: value };
case 'gain':
return { gain: value };
case 'Q':
return { Q: value };
}
}
function parseFrequency(value: string): number | null {
const match = value
.trim()
.toLowerCase()
.replace(/,/g, '')
.replace(/\s+/g, '')
.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(khz|hz|k)?$/);
if (!match) return null;
const parsed = Number(match[1]);
if (!Number.isFinite(parsed)) return null;
return match[2] === 'k' || match[2] === 'khz' ? parsed * 1000 : parsed;
}
function parseDb(value: string): number | null {
const normalized = value.trim().toLowerCase().replace(/\s+/g, '');
const raw = normalized.endsWith('db') ? normalized.slice(0, -2) : normalized;
return parsePlainNumber(raw);
}
function parsePlainNumber(value: string): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
},
note: {
marginTop: spacing.sm,
marginBottom: spacing.xxl,
lineHeight: 20,
},
bands: {
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
band: {
flex: 1,
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
headerActions: {
flexDirection: 'row',
gap: spacing.sm,
},
track: {
width: 4,
height: 140,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
iconButton: {
width: 40,
height: 40,
borderRadius: radius.md,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
knob: {
width: 14,
height: 14,
presetRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.bgTertiary,
},
graphWrap: {
flex: 1,
minHeight: 180,
marginHorizontal: spacing.lg,
},
section: {
marginHorizontal: spacing.lg,
marginTop: spacing.md,
},
bottomBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
preamp: {
flex: 1,
},
eqToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
backgroundColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
freq: {
color: colors.textSecondary,
eqToggleOn: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
sheetTitle: {
marginTop: spacing.xs,
marginBottom: spacing.sm,
},
});
+185 -41
View File
@@ -1,9 +1,12 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQSlider } from '@/components/eq/EQSlider';
import { colors, radius, spacing } from '@/theme';
import { useSettingsStore } from '@/stores/settingsStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
@@ -19,59 +22,159 @@ const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; descri
},
];
const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
{ mode: 'auto', label: 'Auto' },
{ mode: 'track', label: 'Track' },
{ mode: 'album', label: 'Album' },
];
function ToggleRow({
title,
description,
value,
onValueChange,
}: {
title: string;
description: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">{title}</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
);
}
export default function SettingsScreen() {
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
return (
<Screen>
<Text variant="title" style={styles.heading}>
Settings
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<Text variant="title" style={styles.heading}>
Settings
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
LIBRARY
</Text>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
AUDIO
</Text>
<View style={styles.card}>
<ToggleRow
title="Loudness normalization"
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
value={normalizationEnabled}
onValueChange={(v) => void setNormalizationEnabled(v)}
/>
{normalizationEnabled ? (
<View style={styles.indent}>
<EQSlider
label="Target"
value={normalizationTargetLufs}
min={-30}
max={-5}
format={(v) => `${Math.round(v)} LUFS`}
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
/>
</View>
) : null}
</View>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
<View style={[styles.card, styles.cardSpacing]}>
<ToggleRow
title="ReplayGain"
description="Use ReplayGain tags when present; falls back to the measured loudness above."
value={replayGainEnabled}
onValueChange={(v) => void setReplayGainEnabled(v)}
/>
{replayGainEnabled ? (
<View style={styles.modeRow}>
{REPLAYGAIN_MODES.map((m) => {
const selected = m.mode === replayGainMode;
return (
<Pressable
key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)}
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{m.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
</View>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
LIBRARY
</Text>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
paddingBottom: spacing.xxl,
},
heading: {
marginTop: spacing.xl,
marginBottom: spacing.xxl,
@@ -80,6 +183,47 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.sm,
},
sectionSpacing: {
marginTop: spacing.xxl,
},
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
},
cardSpacing: {
marginTop: spacing.sm,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toggleText: {
flex: 1,
gap: 2,
},
indent: {
marginTop: spacing.sm,
},
modeRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.md,
},
modePill: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
modePillSelected: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
settingTitle: {
marginBottom: spacing.xs,
},
+20 -1
View File
@@ -18,6 +18,9 @@ import {
import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
import { useLibraryStore } from '@/stores/libraryStore';
import { useEQStore } from '@/stores/eqStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { colors } from '@/theme';
SplashScreen.preventAutoHideAsync();
@@ -34,6 +37,12 @@ function ScopeLifecycle() {
return null;
}
/** Pushes per-track normalization gain to native on track/settings change. */
function NormalizationSync() {
useNormalizationSync();
return null;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
@@ -51,12 +60,21 @@ export default function RootLayout() {
}, [fontsLoaded]);
// Eager library init: SQLite open + initial reads are tens of ms, and the
// Library tab + playback adapters get data immediately.
// Library tab + playback adapters get data immediately. EQ + audio settings load
// alongside so the native EQ/gain reflect persisted prefs from the first play.
useEffect(() => {
useLibraryStore
.getState()
.initialize()
.catch((err) => console.error('[library] init failed', err));
useEQStore
.getState()
.load()
.catch((err) => console.error('[eq] load failed', err));
useAudioSettingsStore
.getState()
.load()
.catch((err) => console.error('[audioSettings] load failed', err));
}, []);
if (!fontsLoaded) return null;
@@ -67,6 +85,7 @@ export default function RootLayout() {
<StatusBar style="light" />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<Stack
screenOptions={{
headerShown: false,
+63 -40
View File
@@ -16,12 +16,14 @@ import Animated, {
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { MarqueeText } from '@/components/MarqueeText';
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { QueueTray } from '@/components/queue/QueueTray';
import { colors, radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
@@ -177,6 +179,7 @@ export default function NowPlayingScreen() {
const scopeMode = useSettingsStore((s) => s.scopeMode);
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
const libraryTracks = useLibraryStore((s) => s.tracks);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
@@ -194,11 +197,15 @@ export default function NowPlayingScreen() {
const source = track?.album?.trim() ? track.album : 'Library';
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
const artistName = track?.artist.trim() ?? '';
const libraryTrack = useMemo(
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
[libraryTracks, track]
);
const artistName = track
? artistGroupingMode === 'fileTags'
? resolveStrictBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
: resolveCanonicalBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
: '';
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
const navigateToArtist = () => {
@@ -454,9 +461,13 @@ export default function NowPlayingScreen() {
<View style={styles.playerControls}>
<View style={styles.trackInfo}>
<View style={styles.trackTextStack}>
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
<MarqueeText
variant="heading"
containerStyle={styles.trackTitle}
style={styles.trackTitleText}
>
{track.title}
</Text>
</MarqueeText>
<View style={styles.trackMetaRow}>
<Pressable
onPress={navigateToArtist}
@@ -465,13 +476,10 @@ export default function NowPlayingScreen() {
accessibilityRole="link"
accessibilityLabel={`View artist ${track.artist}`}
>
<Text variant="body" numberOfLines={1} style={styles.artist}>
<MarqueeText variant="body" style={styles.artist}>
{track.artist}
</Text>
</MarqueeText>
</Pressable>
<View style={styles.badges}>
<FormatBadges track={track} />
</View>
</View>
</View>
<Pressable
@@ -566,31 +574,36 @@ export default function NowPlayingScreen() {
</View>
<View style={styles.subRow}>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
accessibilityState={{ selected: scopeStageVisible }}
>
<MaterialCommunityIcons
name="sine-wave"
size={SUB_ICON_SIZE + 2}
color={scopeStageVisible ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue"
>
<Ionicons
name="list-outline"
size={SUB_ICON_SIZE + 2}
color={colors.textTertiary}
/>
</Pressable>
<View style={styles.subBadges}>
<FormatBadges track={track} wrap={false} />
</View>
<View style={styles.subActions}>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
accessibilityState={{ selected: scopeStageVisible }}
>
<MaterialCommunityIcons
name="sine-wave"
size={SUB_ICON_SIZE + 2}
color={scopeStageVisible ? colors.accent : colors.textTertiary}
/>
</Pressable>
<Pressable
hitSlop={10}
style={styles.subBtn}
onPress={() => setQueueOpen(true)}
accessibilityLabel="Queue"
>
<Ionicons
name="list-outline"
size={SUB_ICON_SIZE + 2}
color={colors.textTertiary}
/>
</Pressable>
</View>
</View>
</View>
</View>
@@ -778,6 +791,8 @@ const styles = StyleSheet.create({
},
trackTitle: {
alignSelf: 'stretch',
},
trackTitleText: {
textAlign: 'left',
},
inlineActionBtn: {
@@ -789,13 +804,13 @@ const styles = StyleSheet.create({
trackMetaRow: {
alignSelf: 'stretch',
flexDirection: 'row',
flexWrap: 'wrap',
flexWrap: 'nowrap',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.xs,
},
artistButton: {
flexShrink: 1,
flex: 1,
minWidth: 0,
},
centered: {
@@ -804,9 +819,6 @@ const styles = StyleSheet.create({
artist: {
color: colors.accentText,
},
badges: {
flexShrink: 0,
},
spacer: {
flex: 1,
minHeight: MIN_FLOATING_SPACE,
@@ -843,11 +855,22 @@ const styles = StyleSheet.create({
subRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: spacing.lg,
justifyContent: 'space-between',
gap: spacing.md,
marginTop: SUB_TOP_MARGIN,
paddingHorizontal: spacing.sm,
},
subBadges: {
flex: 1,
minWidth: 0,
overflow: 'hidden',
},
subActions: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
gap: spacing.lg,
},
subBtn: {
width: SUB_BUTTON_SIZE,
height: SUB_BUTTON_SIZE,