feat(navidrome): Musikserver-Integration (Subsonic API)

- NavidromeService mit Credential-Verwaltung (secure storage)
- Navidrome-Settings in Settings-Tab: Login/Logout für Server-Verbindung
- Dependencies hinzugefügt: http, crypto, shared_preferences, flutter_secure_storage
- Tests: 70/70 grün
- Analyzer:  keine Fehler

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJzQjUtnvYUtHdnTs3iCru
This commit is contained in:
Hermes (Server)
2026-08-19 07:21:32 +02:00
co-authored by Claude Haiku 4.5
parent 2163c83409
commit 06be0a7065
12 changed files with 533 additions and 9 deletions
+141 -5
View File
@@ -3,15 +3,26 @@ import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/permissions.dart';
import '../services/navidrome_service.dart';
import 'library_stats.dart';
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Über Melo.
///
/// Kein Cloud-/Account-System vorhanden (Phase 2) — daher keine
/// Platzhalter-Einträge für Profil/Sync/Geräte, die aktuell ins Leere laufen.
class SettingsScreen extends StatelessWidget {
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late final NavidromeService _navidrome = NavidromeService();
@override
void initState() {
super.initState();
_navidrome.ladeGespeicherteZugangsdaten();
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
@@ -40,6 +51,24 @@ class SettingsScreen extends StatelessWidget {
},
),
const Divider(height: 1),
const _SectionLabel('Musikserver'),
ListTile(
leading: const Icon(Icons.cloud_circle),
title: const Text('🌐 Navidrome'),
subtitle: _navidrome.istVerbunden
? const Text('✅ Verbunden')
: const Text('Nicht verbunden'),
trailing: _navidrome.istVerbunden
? IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _trennNavidrome(),
)
: IconButton(
icon: const Icon(Icons.login),
onPressed: () => _zeigeNavidromeDialog(),
),
),
const Divider(height: 1),
const _SectionLabel('Berechtigungen'),
ListTile(
leading: const Icon(Icons.mic_none),
@@ -60,6 +89,113 @@ class SettingsScreen extends StatelessWidget {
),
);
}
void _zeigeNavidromeDialog() {
final urlCtrl = TextEditingController();
final userCtrl = TextEditingController();
final passCtrl = TextEditingController();
bool verbindet = false;
String? fehler;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text('🌐 Navidrome Verbinden',
style: TextStyle(color: Colors.white, fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: urlCtrl,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Server-URL',
hintText: 'https://musik.example.com',
labelStyle: TextStyle(color: Colors.grey),
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: userCtrl,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Benutzer',
labelStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: passCtrl,
style: const TextStyle(color: Colors.white),
obscureText: true,
decoration: const InputDecoration(
labelText: 'Passwort',
labelStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
if (fehler != null) ...[
const SizedBox(height: 8),
Text(fehler!,
style: const TextStyle(color: Colors.red, fontSize: 12)),
],
if (verbindet)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.redAccent),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen')),
TextButton(
onPressed: verbindet
? null
: () async {
setDialogState(() => verbindet = true);
_navidrome.setCredentials(
urlCtrl.text, userCtrl.text, passCtrl.text);
final ok = await _navidrome.ping();
setDialogState(() => verbindet = false);
if (ok && ctx.mounted) {
await _navidrome.speichereZugangsdaten(
urlCtrl.text, userCtrl.text, passCtrl.text);
if (ctx.mounted) Navigator.pop(ctx);
if (mounted) setState(() {});
} else if (ctx.mounted) {
setDialogState(() =>
fehler = '❌ Verbindung fehlgeschlagen\nPrüfe URL + Zugangsdaten');
}
},
child: const Text('Verbinden',
style: TextStyle(color: Colors.redAccent)),
),
],
),
),
).then((_) {
urlCtrl.dispose();
userCtrl.dispose();
passCtrl.dispose();
});
}
Future<void> _trennNavidrome() async {
await _navidrome.loescheZugangsdaten();
if (mounted) setState(() {});
}
}
/// Kleine graue Überschrift über einer Einstellungs-Gruppe.