Neue Melo-UI Phase 1: 4 Tabs (Meine Musik/Suche/Favoriten/Download) + Sortierung
- Meine Musik als Startbildschirm ohne Topbar: Kopfzeile (Einstellungen, Suche, Musikerkennung), Schnellzugriffe, Shuffle + Sortieren, Liederliste - Sortierung nach Zuletzt hinzugefuegt / Name (A-Z-#) / Wie oft abgespielt, auf- und absteigend, pro Liste gespeichert (SortStore) - Favoriten als eigener Tab mit gleicher Shuffle-/Sortier-Leiste - Download-Tab uebernimmt den Navidrome-Server-Browser - DB-Schema 3: playCount, gezaehlt beim Titelstart - Theme auf #0B0B10 / #c0392b - Scan-Aktionen aus der entfallenen Topbar jetzt in den Einstellungen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../player/audio_handler.dart';
|
||||
import '../services/navidrome_service.dart';
|
||||
|
||||
/// Download-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
|
||||
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
|
||||
/// und stehen danach offline zur Verfügung.
|
||||
class DownloadsScreen extends StatelessWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Download',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
Expanded(child: _ServerBrowser()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerBrowser extends StatefulWidget {
|
||||
const _ServerBrowser();
|
||||
|
||||
@override
|
||||
State<_ServerBrowser> createState() => _ServerBrowserState();
|
||||
}
|
||||
|
||||
class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
late final NavidromeService _nav = NavidromeService();
|
||||
List<SubsonicAlbum> _albums = [];
|
||||
List<SubsonicArtist> _artists = [];
|
||||
bool _loading = false;
|
||||
bool _showArtists = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nav.ladeGespeicherteZugangsdaten().then((_) {
|
||||
if (_nav.istVerbunden && mounted) {
|
||||
_loadAlbums();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAlbums() async {
|
||||
if (!_nav.istVerbunden) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final alben = await _nav.getAlben(anzahl: 50);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = alben;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
|
||||
});
|
||||
}
|
||||
debugPrint('Fehler beim Laden der Alben: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadArtists() async {
|
||||
if (!_nav.istVerbunden) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final artists = await _nav.getArtists();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_artists = artists;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
|
||||
});
|
||||
}
|
||||
debugPrint('Fehler beim Laden der Künstler: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_nav.istVerbunden) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Musikserver nicht verbunden',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Gehe zu Einstellungen → Navidrome zum Verbinden',
|
||||
style: TextStyle(color: Colors.white30, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.redAccent),
|
||||
);
|
||||
}
|
||||
|
||||
if (_albums.isEmpty) {
|
||||
final hatFehler = _error != null;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(hatFehler ? Icons.error_outline : Icons.album,
|
||||
size: 64,
|
||||
color: hatFehler ? Colors.redAccent : Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
onPressed: _loadAlbums,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => setState(() {
|
||||
_showArtists = false;
|
||||
if (_albums.isEmpty) _loadAlbums();
|
||||
}),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: !_showArtists ? Colors.redAccent : Colors.grey.shade700,
|
||||
),
|
||||
child: const Text('📀 Alben'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => setState(() {
|
||||
_showArtists = true;
|
||||
if (_artists.isEmpty) _loadArtists();
|
||||
}),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _showArtists ? Colors.redAccent : Colors.grey.shade700,
|
||||
),
|
||||
child: const Text('🎤 Künstler'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _showArtists
|
||||
? ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: _artists.length,
|
||||
itemBuilder: (context, i) {
|
||||
final artist = _artists[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person, size: 48),
|
||||
title: Text(artist.name),
|
||||
onTap: () => _playArtist(artist),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: _albums.length,
|
||||
itemBuilder: (context, i) {
|
||||
final album = _albums[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.album,
|
||||
size: 48,
|
||||
color: Colors.redAccent.withValues(alpha: 0.5),
|
||||
),
|
||||
title: Text(album.name),
|
||||
subtitle: Text('${album.songCount} Songs'),
|
||||
onTap: () => _playAlbum(album),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playAlbum(SubsonicAlbum album) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
try {
|
||||
final songs = await _nav.getSongs(album.id);
|
||||
if (songs.isEmpty) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Album hat keine Songs')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final items = songs
|
||||
.map((s) => MediaItem(
|
||||
id: _nav.streamUrl(s.id).toString(),
|
||||
title: s.titel,
|
||||
artist: s.kuenstler,
|
||||
album: s.album,
|
||||
duration: Duration(seconds: s.dauerSekunden),
|
||||
))
|
||||
.toList();
|
||||
await handler.loadPlaylist(items);
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Fehler: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playArtist(SubsonicArtist artist) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
try {
|
||||
final songs = await _nav.getArtistSongs(artist.id);
|
||||
if (songs.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Künstler hat keine Songs')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final items = songs
|
||||
.map((s) => MediaItem(
|
||||
id: _nav.streamUrl(s.id).toString(),
|
||||
title: s.titel,
|
||||
artist: s.kuenstler,
|
||||
album: s.album,
|
||||
duration: Duration(seconds: s.dauerSekunden),
|
||||
))
|
||||
.toList();
|
||||
await handler.loadPlaylist(items);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Fehler: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user