m5, subsonic/jellyfin support

This commit is contained in:
Boof2015
2026-06-27 22:03:37 -04:00
parent 0e3e46293c
commit 3ec3f5ecf2
37 changed files with 3322 additions and 35 deletions
+4 -3
View File
@@ -22,7 +22,7 @@ import {
togglePlay,
} from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { albumArtworkSource } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useScopeActive } from '@/scope/scopeStore';
import type { PlaybackState, Track } from '@/types/audio';
@@ -96,11 +96,12 @@ function SectionHeader({
}
function AlbumCover({ album, size }: { album: Album; size: number }) {
const artUri = albumArtworkSource(album);
return (
<View style={[styles.albumArt, { width: size, height: size }]}>
{album.artwork_hash ? (
{artUri ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: artUri }}
style={styles.image}
contentFit="cover"
transition={120}
+3 -3
View File
@@ -14,7 +14,7 @@ import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { artworkUri } from '@/library/artwork';
import { albumArtworkSource } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import type { DbTrack } from '@/types/library';
@@ -51,9 +51,9 @@ export default function AlbumScreen() {
<View style={styles.header}>
<View style={styles.art}>
{album?.artwork_hash ? (
{album && albumArtworkSource(album) ? (
<Image
source={{ uri: artworkUri(album.artwork_hash) }}
source={{ uri: albumArtworkSource(album)! }}
style={styles.artImage}
contentFit="cover"
transition={120}
+25
View File
@@ -1,11 +1,13 @@
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
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 { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
@@ -58,6 +60,9 @@ function ToggleRow({
}
export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -166,6 +171,26 @@ export default function SettingsScreen() {
);
})}
</View>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
REMOTE SOURCES
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/sources')}
accessibilityRole="button"
>
<Ionicons name="server-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Subsonic / Jellyfin servers</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{remoteSources.length === 0
? 'Stream and browse your self-hosted library.'
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
</ScrollView>
</Screen>
);
+7
View File
@@ -20,6 +20,7 @@ import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
import { useLibraryStore } from '@/stores/libraryStore';
import { useEQStore } from '@/stores/eqStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useNormalizationSync } from '@/audio/useNormalizationSync';
import { colors } from '@/theme';
@@ -75,6 +76,12 @@ export default function RootLayout() {
.getState()
.load()
.catch((err) => console.error('[audioSettings] load failed', err));
// Remote sources: load server rows + hydrate the URL registry from cached
// config/token (no network on launch). Runs after library init reads first.
useRemoteSourcesStore
.getState()
.init()
.catch((err) => console.error('[remoteSources] init failed', err));
}, []);
if (!fontsLoaded) return null;
+5
View File
@@ -16,6 +16,7 @@ import Animated, {
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
import { MarqueeText } from '@/components/MarqueeText';
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
import { Visualizer } from '@/components/Visualizer';
@@ -575,6 +576,7 @@ export default function NowPlayingScreen() {
<View style={styles.subRow}>
<View style={styles.subBadges}>
<RemoteSourceBadge sourceType={track.sourceType} />
<FormatBadges track={track} wrap={false} />
</View>
<View style={styles.subActions}>
@@ -864,6 +866,9 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
overflow: 'hidden',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
subActions: {
flexDirection: 'row',
+402
View File
@@ -0,0 +1,402 @@
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote';
const TYPE_OPTIONS: {
type: RemoteSourceType;
label: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
}[] = [
{
type: 'subsonic',
label: 'Subsonic',
description: 'Navidrome, Airsonic, Gonic, and other Subsonic-compatible servers.',
icon: 'cloud-outline',
},
{
type: 'jellyfin',
label: 'Jellyfin',
description: 'A Jellyfin media server.',
icon: 'tv-outline',
},
];
interface FieldProps {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
secureTextEntry?: boolean;
autoCapitalize?: 'none' | 'sentences';
keyboardType?: 'default' | 'url';
}
function Field({
label,
value,
onChangeText,
placeholder,
secureTextEntry,
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
{label}
</Text>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
secureTextEntry={secureTextEntry}
autoCapitalize={autoCapitalize}
autoCorrect={false}
keyboardType={keyboardType}
/>
</View>
);
}
export default function SourceEditScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const sources = useRemoteSourcesStore((s) => s.sources);
const createSource = useRemoteSourcesStore((s) => s.createSource);
const updateSource = useRemoteSourcesStore((s) => s.updateSource);
const testSource = useRemoteSourcesStore((s) => s.testSource);
const editing = useMemo(
() => (id ? sources.find((s) => s.id === Number(id)) : undefined),
[id, sources]
);
// Wizard: pick a server type first (new only), then enter connection details.
const [step, setStep] = useState<'type' | 'details'>(editing ? 'details' : 'type');
const [type, setType] = useState<RemoteSourceType>(editing?.type ?? 'subsonic');
const [name, setName] = useState(editing?.name ?? '');
const [baseUrl, setBaseUrl] = useState(editing?.base_url ?? '');
const [username, setUsername] = useState(editing?.username ?? '');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState<'test' | 'save' | null>(null);
const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null);
const typeLabel = type === 'subsonic' ? 'Subsonic' : 'Jellyfin';
const canSubmit =
name.trim().length > 0 &&
baseUrl.trim().length > 0 &&
username.trim().length > 0 &&
(editing ? true : password.length > 0);
const chooseType = (next: RemoteSourceType) => {
setType(next);
setMessage(null);
setStep('details');
};
const goBack = () => {
// From details on a NEW server, step back to type selection; otherwise leave.
if (step === 'details' && !editing) {
setMessage(null);
setStep('type');
return;
}
router.back();
};
const onTest = async () => {
if (busy) return;
if (!baseUrl.trim() || !username.trim() || !password) {
setMessage({ text: 'Enter server URL, username, and password to test.', ok: false });
return;
}
setBusy('test');
setMessage(null);
const result = await testSource({
type,
baseUrl: baseUrl.trim(),
username: username.trim(),
password,
});
setMessage({ text: result.message, ok: result.ok });
setBusy(null);
};
const onSave = async () => {
if (busy || !canSubmit) return;
setBusy('save');
setMessage(null);
try {
if (editing) {
await updateSource(editing.id, {
name: name.trim(),
baseUrl: baseUrl.trim(),
username: username.trim(),
password: password || undefined,
});
} else {
await createSource({
type,
name: name.trim(),
baseUrl: baseUrl.trim(),
username: username.trim(),
password,
enabled: true,
});
}
router.back();
} catch (error) {
setMessage({ text: error instanceof Error ? error.message : String(error), ok: false });
setBusy(null);
}
};
const backLabel = step === 'details' && !editing ? 'Type' : 'Servers';
return (
<Screen>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.header}>
<Pressable style={styles.back} onPress={goBack} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
{backLabel}
</Text>
</Pressable>
</View>
{step === 'type' ? (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<Text variant="title" style={styles.heading}>
Add server
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.subheading}>
Choose your server type to get started.
</Text>
<View style={styles.typeCards}>
{TYPE_OPTIONS.map((option) => (
<Pressable
key={option.type}
style={styles.typeCard}
onPress={() => chooseType(option.type)}
accessibilityRole="button"
>
<View style={styles.typeCardIcon}>
<Ionicons name={option.icon} size={24} color={colors.accent} />
</View>
<View style={styles.typeCardText}>
<Text variant="body">{option.label}</Text>
<Text
variant="caption"
color={colors.textSecondary}
style={styles.typeCardDesc}
>
{option.description}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
))}
</View>
</ScrollView>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text variant="title" style={styles.heading}>
{editing ? 'Edit server' : `${typeLabel} server`}
</Text>
<Field
label="NAME"
value={name}
onChangeText={setName}
placeholder={`My ${typeLabel}`}
autoCapitalize="sentences"
/>
<Field
label="SERVER URL"
value={baseUrl}
onChangeText={setBaseUrl}
placeholder="http://192.168.1.50:4533"
keyboardType="url"
/>
<Field label="USERNAME" value={username} onChangeText={setUsername} placeholder="username" />
<Field
label="PASSWORD"
value={password}
onChangeText={setPassword}
placeholder={editing ? 'Unchanged' : 'password'}
secureTextEntry
/>
{message ? (
<Text
variant="caption"
color={message.ok ? colors.accent : colors.warning}
style={styles.message}
>
{message.text}
</Text>
) : null}
<View style={styles.actions}>
<Pressable
style={[styles.button, styles.secondaryButton, busy ? styles.buttonDisabled : null]}
onPress={() => void onTest()}
disabled={!!busy}
>
{busy === 'test' ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text variant="body" color={colors.textSecondary}>
Test connection
</Text>
)}
</Pressable>
<Pressable
style={[
styles.button,
styles.primaryButton,
!canSubmit || busy ? styles.buttonDisabled : null,
]}
onPress={() => void onSave()}
disabled={!canSubmit || !!busy}
>
{busy === 'save' ? (
<ActivityIndicator size="small" color={colors.accentTextStrong} />
) : (
<Text variant="body" color={colors.accentTextStrong}>
{editing ? 'Save' : 'Add server'}
</Text>
)}
</Pressable>
</View>
</ScrollView>
)}
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.md,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
content: {
paddingBottom: spacing.xxl,
},
heading: {
marginTop: spacing.lg,
marginBottom: spacing.sm,
},
subheading: {
marginBottom: spacing.xl,
},
typeCards: {
gap: spacing.md,
},
typeCard: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
typeCardIcon: {
width: 44,
height: 44,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
typeCardText: {
flex: 1,
gap: 2,
},
typeCardDesc: {
lineHeight: 16,
},
field: {
marginTop: spacing.lg,
},
fieldLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
input: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
color: colors.textPrimary,
fontSize: 15,
},
message: {
marginTop: spacing.lg,
lineHeight: 18,
},
actions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.xl,
},
button: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md + 2,
borderRadius: radius.md,
minHeight: 48,
},
secondaryButton: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
primaryButton: {
backgroundColor: colors.accent,
},
buttonDisabled: {
opacity: 0.5,
},
});
+267
View File
@@ -0,0 +1,267 @@
import { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import { colors, radius, spacing } from '@/theme';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';
function statusLine(
source: RemoteSourceRow,
progress: RemoteSyncProgress | null
): { text: string; tone: 'normal' | 'error' } {
if (!source.enabled) return { text: 'Disabled', tone: 'normal' };
if (progress) {
const pct = progress.total > 0 ? ` ${progress.current}/${progress.total}` : '';
return { text: `Syncing… ${progress.phase}${pct}`, tone: 'normal' };
}
if (source.last_status === 'error') {
return { text: source.last_error ?? 'Sync failed', tone: 'error' };
}
if (source.last_sync_at) {
return { text: `Last synced ${new Date(source.last_sync_at).toLocaleString()}`, tone: 'normal' };
}
return { text: 'Not synced yet', tone: 'normal' };
}
export default function SourcesScreen() {
const router = useRouter();
const sources = useRemoteSourcesStore((s) => s.sources);
const progressById = useRemoteSourcesStore((s) => s.progressById);
const syncSource = useRemoteSourcesStore((s) => s.syncSource);
const syncAll = useRemoteSourcesStore((s) => s.syncAll);
const deleteSource = useRemoteSourcesStore((s) => s.deleteSource);
const [actionFor, setActionFor] = useState<RemoteSourceRow | null>(null);
const confirmRemove = (source: RemoteSourceRow) => {
Alert.alert(
`Remove ${source.name}?`,
'This removes the server and all of its tracks from your library. Favorites and playlist entries are kept but will show as missing.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => void deleteSource(source.id, true),
},
]
);
};
const actionItems: ActionSheetItem[] = actionFor
? [
{
key: 'sync',
label: 'Sync now',
icon: 'sync',
onPress: () => {
const id = actionFor.id;
setActionFor(null);
void syncSource(id);
},
},
{
key: 'edit',
label: 'Edit server',
icon: 'create-outline',
onPress: () => {
const id = actionFor.id;
setActionFor(null);
router.push({ pathname: '/sources/edit', params: { id: String(id) } });
},
},
{
key: 'remove',
label: 'Remove server',
icon: 'trash-outline',
destructive: true,
onPress: () => {
const source = actionFor;
setActionFor(null);
confirmRemove(source);
},
},
]
: [];
return (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
<View style={styles.headerActions}>
{sources.length > 0 ? (
<Pressable onPress={() => void syncAll()} hitSlop={8} accessibilityLabel="Sync all">
<Ionicons name="sync" size={20} color={colors.textSecondary} />
</Pressable>
) : null}
<Pressable
onPress={() => router.push('/sources/edit')}
hitSlop={8}
accessibilityLabel="Add server"
>
<Ionicons name="add" size={26} color={colors.accent} />
</Pressable>
</View>
</View>
<Text variant="title" style={styles.heading}>
Remote sources
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
{sources.length === 0 ? (
<View style={styles.empty}>
<Ionicons name="server-outline" size={28} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary} style={styles.emptyText}>
No servers yet. Add a Subsonic or Jellyfin server to stream and browse your
self-hosted library.
</Text>
<Pressable style={styles.addButton} onPress={() => router.push('/sources/edit')}>
<Ionicons name="add" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Add server
</Text>
</Pressable>
</View>
) : (
sources.map((source) => {
const status = statusLine(source, progressById[source.id] ?? null);
return (
<Pressable
key={source.id}
style={styles.row}
onPress={() => setActionFor(source)}
accessibilityRole="button"
>
<View style={styles.rowIcon}>
<Ionicons
name={source.type === 'subsonic' ? 'cloud-outline' : 'tv-outline'}
size={20}
color={colors.accent}
/>
</View>
<View style={styles.rowMeta}>
<View style={styles.rowTitleLine}>
<Text variant="body" numberOfLines={1} style={styles.rowName}>
{source.name}
</Text>
<Text variant="label" color={colors.textTertiary}>
{source.type.toUpperCase()}
</Text>
</View>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{source.base_url}
</Text>
<Text
variant="caption"
color={status.tone === 'error' ? colors.warning : colors.textSecondary}
numberOfLines={1}
>
{status.text}
</Text>
</View>
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textTertiary} />
</Pressable>
);
})
)}
</ScrollView>
<ActionSheet
visible={actionFor !== null}
title={actionFor?.name}
items={actionItems}
onClose={() => setActionFor(null)}
/>
</Screen>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.md,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
heading: {
marginTop: spacing.lg,
marginBottom: spacing.lg,
},
content: {
paddingBottom: spacing.xxl,
gap: spacing.sm,
},
empty: {
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
},
emptyText: {
textAlign: 'center',
lineHeight: 20,
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
marginTop: spacing.sm,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
rowIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
rowMeta: {
flex: 1,
gap: 2,
},
rowTitleLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
rowName: {
flex: 1,
},
});