mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
m5, subsonic/jellyfin support
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user