This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/screens/home_screen.dart
T
Dustin fdfc166807 v2.51.2 — 5-Tab-Navigation (Rechtshänder) + Musikserver-Status
## Navigation (home_screen.dart)
- Neue Tab-Struktur: Start / Bibliothek / Jetzt läuft / Cloud / Mehr (NavigationBar, Icons home, library_music, play_circle_fill, cloud, more_horiz)
- ▶️ Jetzt läuft (Daumen-Zone rechts): öffnet Now-Playing-Fullscreen direkt; bei Pause Hinweis-Screen
- Start: Weiterhören (Zuletzt gehört), Recap-Karte, Statistik, Favoriten-Schnellzugriff, neue Downloads
- Bibliothek: Segmente Songs/Alben/Künstler/Jahre/Genres/Tags/Playlists/Favoriten/Downloads + 'Lied +'- und Such-Buttons oben
- Mehr: Einstellungen, Erweitert, Info, Abmelden
- Keine Funktionalität verloren: YT-Download, Scanner, Recap, Lyrics, Queue erreichbar

## Musikserver (Feature 2)
- 'Navidrome verbinden' → '☁️ Musikserver' + Statuspunkt (grün=verbunden, rot=offline, pulsierend=Sync läuft)
- Detail-Dialog: 'Verbunden mit Navidrome · musik.baka-net.de', Verbinden/Trennen/Browser
- Gespeicherte Zugangsdaten werden beim App-Start geladen
2026-08-05 07:57:01 +02:00

1444 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'dart:async';
import '../viewmodels/melo_home_viewmodel.dart';
import '../models/song.dart';
import '../models/playlist.dart';
import '../utils/farb_theme.dart';
import '../widgets/mini_player.dart';
import '../widgets/melo_header.dart';
import '../widgets/statistik_card.dart';
import '../widgets/tag_leiste.dart';
import '../widgets/song_tile.dart';
import '../widgets/recent_widget.dart';
import 'download_screen.dart';
import 'cloud_screen.dart';
import 'settings_screen.dart';
import 'login_screen.dart';
import 'recap_screen.dart';
import 'now_playing_screen.dart';
import 'erweitert_screen.dart';
import '../services/cloud_service.dart';
import '../services/player_service.dart';
import '../services/auth_service.dart';
import '../config/app_config.dart';
class MeloHome extends StatefulWidget {
const MeloHome({super.key});
@override
State<MeloHome> createState() => _MeloHomeState();
}
class _MeloHomeState extends State<MeloHome> {
final MeloHomeViewModel _vm = MeloHomeViewModel();
final CloudService _cloud = CloudService();
/// 0=Start, 1=Bibliothek, 2=Jetzt läuft, 3=Cloud, 4=Mehr
int _aktiverTab = 0;
/// Aktives Segment im Bibliothek-Tab
String _bibSegment = 'Songs';
static const _bibSegmente = [
'Songs', 'Alben', 'Künstler', 'Jahre', 'Genres',
'Tags', 'Playlists', 'Favoriten', 'Downloads',
];
@override
void initState() {
super.initState();
_vm.ladeSongs();
// Gespeicherte Navidrome-Zugangsdaten laden → Musikserver-Statuspunkt
_vm.navidrome.ladeGespeicherteZugangsdaten().then((_) {
if (mounted) setState(() {});
});
// Cloud-Verbindung beim App-Start herstellen (feuern-und-vergessen).
_cloud.verbinde();
}
@override
void dispose() {
_vm.dispose();
super.dispose();
}
// ─── Navigation ─────────────────────────────────────
/// Öffnet den Now-Playing-Fullscreen mit Slide-up-Animation
void _oeffneNowPlaying() {
Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, __, ___) => const NowPlayingScreen(),
transitionsBuilder: (_, animation, __, child) {
final offset = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
);
return SlideTransition(position: offset, child: child);
},
),
);
}
Future<void> _zeigeSuche() async {
final controller = TextEditingController();
String modus = 'Alle';
final ergebnis = await showDialog<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🔍 Song suchen', style: TextStyle(color: Colors.white, fontSize: 18)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Titel, Künstler oder Tag...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
_suchChip('Alle', modus == 'Alle', () => setDialogState(() => modus = 'Alle')),
const SizedBox(width: 6),
_suchChip('Titel', modus == 'Titel', () => setDialogState(() => modus = 'Titel')),
const SizedBox(width: 6),
_suchChip('Künstler', modus == 'Künstler', () => setDialogState(() => modus = 'Künstler')),
const SizedBox(width: 6),
_suchChip('Tag', modus == 'Tag', () => setDialogState(() => modus = 'Tag')),
],
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.pop(ctx, '${controller.text}|$modus'),
child: const Text('Suchen', style: TextStyle(color: MeloTheme.rot)),
),
],
),
),
);
controller.dispose();
if (ergebnis == null || ergebnis.isEmpty) return;
final teile = ergebnis.split('|');
final suchtext = teile[0].toLowerCase();
final suchModus = teile.length > 1 ? teile[1] : 'Alle';
if (suchtext.isEmpty) return;
// Tag-Namen die zum Suchtext passen
final matchingTags = _vm.tags
.where((t) => t['name'] != 'Alle' && t['name']!.toLowerCase().contains(suchtext))
.map((t) => t['name']!)
.toSet();
final gefiltert = _vm.songs.where((s) {
if (suchModus == 'Titel') return s.titel.toLowerCase().contains(suchtext);
if (suchModus == 'Künstler') return s.kuenstler.toLowerCase().contains(suchtext);
if (suchModus == 'Tag') {
return s.tagIds != null && matchingTags.any((name) {
final tag = _vm.tagsMap[name];
return tag != null && s.tagIds!.contains(tag.id);
});
}
// Alle
if (s.titel.toLowerCase().contains(suchtext)) return true;
if (s.kuenstler.toLowerCase().contains(suchtext)) return true;
return s.tagIds != null && matchingTags.any((name) {
final tag = _vm.tagsMap[name];
return tag != null && s.tagIds!.contains(tag.id);
});
}).toList();
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text('🔍 ${gefiltert.length} Treffer', style: const TextStyle(color: Colors.white)),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: gefiltert.isEmpty
? const Center(child: Text('Keine Treffer', style: TextStyle(color: Colors.grey)))
: ListView.builder(
itemCount: gefiltert.length,
itemBuilder: (_, i) => ListTile(
leading: Container(
width: 36, height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 14, color: Colors.white54))),
),
title: Text(gefiltert[i].titel, style: const TextStyle(color: Colors.white)),
subtitle: Text(gefiltert[i].kuenstler, style: const TextStyle(color: Colors.grey)),
onTap: () { Navigator.pop(ctx); _vm.spieleSong(gefiltert[i]); },
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
),
);
}
Future<void> _scanMusik() async {
final erlaubt = await _vm.scanner.frageSpeicherZugriff();
if (!erlaubt) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bitte Speicherzugriff erlauben')),
);
}
return;
}
await _vm.scanner.scanneMusikOrdner();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scannen fertig: ${_vm.scanner.anzahlNeueSongs} neue Songs gefunden')),
);
await _vm.ladeSongs();
}
}
void _zeigeServerBrowser() {
if (_vm.navidrome.istVerbunden) {
// Abmelden
_vm.navidrome.setCredentials('', '', '');
_vm.navidromeAlben.clear();
setState(() {});
return;
}
// Verbinden: einfacher Login-Dialog
final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl);
final userCtrl = TextEditingController();
final passCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🌐 Musikserver verbinden', style: TextStyle(color: Colors.white, fontSize: 15)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Server', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: userCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Benutzer', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: passCtrl, obscureText: true, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Passwort', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
_vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text);
await _vm.ladeNavidromeAlben();
await _vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text);
if (ctx.mounted) Navigator.pop(ctx);
setState(() {});
},
child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)),
),
],
),
).then((_) {
urlCtrl.dispose();
userCtrl.dispose();
passCtrl.dispose();
});
}
/// Detail-Dialog zum ☁️ Musikserver-Status (Feature: Umbenennung von
/// „Navidrome verbinden“ → „Musikserver“).
Future<void> _zeigeMusikserverDetail() async {
final verbunden = _vm.navidrome.istVerbunden;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Row(
children: [
Text('☁️', style: TextStyle(fontSize: 20)),
SizedBox(width: 8),
Text('Musikserver', style: TextStyle(color: Colors.white, fontSize: 17)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: verbunden ? const Color(0xFF4CAF50) : const Color(0xFFEF5350),
),
),
const SizedBox(width: 8),
Text(
verbunden ? 'Verbunden' : 'Offline',
style: TextStyle(
color: verbunden ? const Color(0xFFA5D6A7) : const Color(0xFFEF9A9A),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 10),
Text(
verbunden
? 'Verbunden mit Navidrome · musik.baka-net.de'
: 'Nicht mit dem Musikserver verbunden.\nTippe auf „Verbinden“, um Alben zu durchstöbern.',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen')),
if (verbunden)
TextButton(
onPressed: () {
Navigator.pop(ctx);
_zeigeServerBrowser(); // trennt (Credentials leeren)
},
child: const Text('Trennen', style: TextStyle(color: MeloTheme.textSekundaer)),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
if (!verbunden) _zeigeServerBrowser();
},
child: Text(
verbunden ? 'Browser öffnen' : 'Verbinden',
style: const TextStyle(color: MeloTheme.rot),
),
),
],
),
);
}
void _zeigeAddToPlaylist(Song song) async {
final playlists = await _vm.playlists.allePlaylists();
if (!mounted || playlists.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erst eine Playlist erstellen')),
);
}
return;
}
final auswahl = await showDialog<Playlist>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('Zu Playlist hinzufügen', style: TextStyle(color: Colors.white, fontSize: 16)),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.queue_music, color: MeloTheme.rot, size: 18),
title: Text(playlists[i].name, style: const TextStyle(color: Colors.white, fontSize: 14)),
subtitle: Text('${playlists[i].songCount} Songs', style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
onTap: () => Navigator.pop(ctx, playlists[i]),
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen'))],
),
);
if (auswahl != null && song.id != null) {
await _vm.playlists.songHinzufuegen(auswahl.id!, song.id!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ ${auswahl.name}')),
);
}
}
}
void _zeigeDownloadDialog() {
// „Lied+“ (YT-Download) als eigenen Screen öffnen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DownloadScreen(
downloader: _vm.downloader,
onSongsChanged: _vm.ladeSongs,
),
),
);
}
/// Korrupten Song mit bekannter ytUrl neu herunterladen
Future<void> _erneutHerunterladen(Song song) async {
final neu = await _vm.downloader.reDownload(song);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(neu != null ? '🔄 Neu geladen: ${neu.titel}' : 'Fehler: ${_vm.downloader.fehler ?? 'unbekannt'}'),
),
);
await _vm.ladeSongs();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _vm,
builder: (_, _) {
if (_vm.ladt) {
return const Scaffold(
backgroundColor: MeloTheme.schwarz,
body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)),
);
}
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: SafeArea(
child: switch (_aktiverTab) {
1 => _bibliothekTab(),
2 => _jetztLaeuftTab(),
3 => CloudScreen(
cloud: _cloud,
onZurueck: () => setState(() => _aktiverTab = 0),
),
4 => _mehrTab(),
_ => _startTab(),
},
),
bottomNavigationBar: _bottomNav(),
);
},
);
}
// ─── Tab: 🏠 Start ──────────────────────────────────
Widget _startTab() {
return Column(
children: [
MeloHeader(
onDownload: _zeigeDownloadDialog,
onSearch: _zeigeSuche,
onServer: _zeigeMusikserverDetail,
serverVerbunden: _vm.navidrome.istVerbunden,
serverSyncLaeuft: _cloud.status == CloudStatus.verbinde || _vm.serverLadt,
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 8),
children: [
// Weiterhören / Zuletzt gehört
RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
// Jahres-Recap (wie Spotify Wrapped)
_recapKarte(),
// Hidden Message "Seit 2008"
if (_vm.zeigeBotschaft) _botschaftBanner(),
StatistikCard(
anzahlSongs: _vm.songs.length,
gesamtMB: _vm.songs.isEmpty
? '0'
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0),
gesamtMin: _vm.songs.isEmpty
? 0
: (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(),
anzahlFavoriten: _vm.favoritenIds.length,
),
_favoritenSchnellzugriff(),
_neueDownloads(),
],
),
),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
/// ⭐ Favoriten-Schnellzugriff auf dem Start-Tab (horizontale Chips)
Widget _favoritenSchnellzugriff() {
final favoriten = _vm.songs
.where((s) => s.id != null && _vm.favoritenIds.contains(s.id))
.take(8)
.toList();
if (favoriten.isEmpty) return const SizedBox.shrink();
return _horizontaleListe(
titel: '⭐ Deine Favoriten',
songs: favoriten,
leerText: '',
);
}
/// ⬇ Neue Downloads auf dem Start-Tab (die 5 zuletzt hinzugefügten)
Widget _neueDownloads() {
final neue = _vm.songs.where((s) => s.istHeruntergeladen).take(5).toList();
if (neue.isEmpty) return const SizedBox.shrink();
return _horizontaleListe(
titel: '⬇ Neue Downloads',
songs: neue,
leerText: '',
);
}
Widget _horizontaleListe({
required String titel,
required List<Song> songs,
required String leerText,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white70),
),
const SizedBox(height: 8),
SizedBox(
height: 52,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: songs.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _vm.spieleSong(songs[i]),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: MeloTheme.rot.withValues(alpha: 0.3),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 12))),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(songs[i].titel,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: Colors.white)),
Text(songs[i].kuenstler,
style: const TextStyle(fontSize: 9, color: MeloTheme.textSekundaer)),
],
),
],
),
),
),
),
),
],
),
);
}
// ─── Tab: 📚 Bibliothek ─────────────────────────────
Widget _bibliothekTab() {
return Column(
children: [
_bibliothekHeader(),
_segmentLeiste(),
Expanded(child: _bibliothekInhalt()),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
Widget _bibliothekHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'📚 Bibliothek',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white),
),
Text(
'Alles an einem Ort',
style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer),
),
],
),
Row(children: [
// „Lied+“ (YT-Download) in die Bibliothek integriert
_bibButton(Icons.add_circle_outline, 'Lied +', _zeigeDownloadDialog),
const SizedBox(width: 8),
_bibButton(Icons.search, 'Suche', _zeigeSuche),
]),
],
),
);
}
Widget _bibButton(IconData icon, String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: MeloTheme.rot),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.w500)),
],
),
),
);
}
Widget _segmentLeiste() {
return SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
children: _bibSegmente.map((seg) {
final aktiv = _bibSegment == seg;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => setState(() => _bibSegment = seg),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(16),
),
child: Text(
seg,
style: TextStyle(
fontSize: 12,
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
fontWeight: FontWeight.w600,
),
),
),
),
);
}).toList(),
),
);
}
Widget _bibliothekInhalt() {
switch (_bibSegment) {
case 'Alben':
return _gruppenListe(_gruppiereNach((s) => s.album));
case 'Künstler':
return _gruppenListe(_gruppiereNach((s) => s.kuenstler));
case 'Jahre':
return _gruppenListe(_gruppiereNach((s) => s.jahr ?? ''));
case 'Genres':
return _gruppenListe(_gruppiereNach((s) => s.genre ?? ''));
case 'Tags':
return _tagsSegment();
case 'Playlists':
return _playlistsSegment();
case 'Favoriten':
final favoriten = _vm.songs
.where((s) => s.id != null && _vm.favoritenIds.contains(s.id))
.toList();
return _songListe(quelle: favoriten, titel: '⭐ Favoriten');
case 'Downloads':
return _downloadsSegment();
default:
return Column(
children: [
TagLeiste(
tags: _vm.tags,
aktiveTags: _vm.aktiveTags,
onTagToggled: _vm.toggleTag,
),
Expanded(
child: _songListe(titel: '📂 Alle Songs'),
),
],
);
}
}
/// Gruppiert Songs nach einem Schlüssel (Album, Künstler, Jahr, Genre)
Map<String, List<Song>> _gruppiereNach(String Function(Song) schluessel) {
final gruppen = <String, List<Song>>{};
for (final s in _vm.songs) {
final key = schluessel(s).trim();
if (key.isEmpty) continue;
gruppen.putIfAbsent(key, () => []).add(s);
}
return gruppen;
}
/// Expandierbare Gruppen-Liste (Alben / Künstler / Jahre / Genres)
Widget _gruppenListe(Map<String, List<Song>> gruppen) {
final keys = gruppen.keys.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
if (keys.isEmpty) {
return const Center(
child: Text('Keine Einträge', style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13)),
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
children: keys.map((key) {
final songs = gruppen[key]!;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
leading: const Icon(Icons.folder_outlined, color: MeloTheme.rot, size: 18),
title: Text(
key,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: Text(
'${songs.length} Song${songs.length != 1 ? 's' : ''}',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11),
),
childrenPadding: const EdgeInsets.only(bottom: 6),
children: songs.map((s) => SongTile(
song: s,
istFavorit: s.id != null && _vm.favoritenIds.contains(s.id),
onFavoriteToggle: _vm.favoritenUmschalten,
onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist,
onErneutHerunterladen: _erneutHerunterladen,
onSpieleAlsNaechstes: (song) {
_vm.spieleAlsNaechstes(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('▶ Als Nächstes: ${song.titel}')),
);
},
onAmEndeHinzufuegen: (song) {
_vm.amEndeHinzufuegen(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ Am Ende hinzugefügt: ${song.titel}')),
);
},
)).toList(),
),
),
);
}).toList(),
);
}
/// 🏷 Tags-Segment: Tag-Liste mit Zählern; Tipp filtert die Songliste
Widget _tagsSegment() {
final eintraege = _vm.tagCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (eintraege.isEmpty) {
return const Center(
child: Text('Noch keine Tags', style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13)),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: [
...eintraege.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
_vm.aktiveTags = {e.key};
setState(() => _bibSegment = 'Songs');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
const Icon(Icons.label_outline, color: MeloTheme.rot, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
e.key,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
'${e.value} Song${e.value != 1 ? 's' : ''}',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 18),
],
),
),
),
),
)),
],
);
}
/// 📋 Playlists-Segment: Playlist-Liste, Tipp zeigt die Songs
Widget _playlistsSegment() {
return FutureBuilder<List<Playlist>>(
future: _vm.playlists.allePlaylists(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator(color: MeloTheme.rot));
}
final playlists = snapshot.data ?? [];
if (playlists.isEmpty) {
return const Center(
child: Text(
'Noch keine Playlists\nTippe in der Songliste auf + um eine zu erstellen',
textAlign: TextAlign.center,
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: playlists.map((pl) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _zeigePlaylistSongs(pl),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
const Icon(Icons.queue_music, color: MeloTheme.rot, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
pl.name,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
'${pl.songCount} Songs',
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 18),
],
),
),
),
),
)).toList(),
);
},
);
}
Future<void> _zeigePlaylistSongs(Playlist pl) async {
final songs = await _vm.playlists.songs(pl.id!);
if (!mounted) return;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Text('📋 ${pl.name}', style: const TextStyle(color: Colors.white, fontSize: 17)),
content: SizedBox(
width: double.maxFinite,
height: 360,
child: songs.isEmpty
? const Center(child: Text('Leere Playlist', style: TextStyle(color: MeloTheme.textSekundaer)))
: ListView.builder(
itemCount: songs.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.music_note, color: MeloTheme.rot, size: 18),
title: Text(songs[i].titel, style: const TextStyle(color: Colors.white, fontSize: 14)),
subtitle: Text(songs[i].kuenstler, style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 11)),
onTap: () {
Navigator.pop(ctx);
_vm.spieleSong(songs[i], warteschlange: songs);
},
),
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
),
);
}
/// ⬇ Downloads-Segment: lokale Songs + „Lied+“-Einstieg
Widget _downloadsSegment() {
final downloads = _vm.songs.where((s) => s.istHeruntergeladen).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: GestureDetector(
onTap: _zeigeDownloadDialog,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF3A0000), Color(0xFF1A0000)]),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.45)),
),
child: const Row(
children: [
Icon(Icons.add_circle_outline, color: MeloTheme.rot, size: 22),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('⬇ Lied +',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white)),
Text('Neuen Song von YouTube herunterladen',
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
),
),
Icon(Icons.chevron_right, color: MeloTheme.rot, size: 20),
],
),
),
),
),
Expanded(
child: _songListe(quelle: downloads, titel: '📂 Downloads'),
),
],
);
}
// ─── Tab: ▶️ Jetzt läuft ────────────────────────────
Widget _jetztLaeuftTab() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_circle_outline, size: 64, color: MeloTheme.dunkel2),
const SizedBox(height: 16),
const Text(
'Nichts spielt gerade',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
const SizedBox(height: 6),
const Text(
'Wähle einen Song in deiner Bibliothek,\ndann findest du ihn hier.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: MeloTheme.textSekundaer),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _aktiverTab = 1),
style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.rot,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Zur Bibliothek'),
),
],
),
);
}
// ─── Tab: ⚙️ Mehr ───────────────────────────────────
Widget _mehrTab() {
return Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'⚙️ Mehr',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white),
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
_mehrKachel(
icon: Icons.settings_outlined,
titel: 'Einstellungen',
untertitel: 'Cloud Sync, Musikserver, Recap & mehr',
onTap: _oeffneEinstellungen,
),
_mehrKachel(
icon: Icons.handyman_outlined,
titel: 'Erweitert',
untertitel: 'Logs, Diagnose, Entwickler & Scanner',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ErweitertScreen(onScan: _scanMusik),
),
);
},
),
const SizedBox(height: 8),
_sektionHeader('Info'),
Container(
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Column(
children: [
_infoZeile('Version', '2.51'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Theme', 'Schwarz + Rot'),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Musikserver', AppConfig.navidromeUrl),
const Divider(color: MeloTheme.dunkel2, height: 1),
_infoZeile('Cloud', AppConfig.cloudUrl),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () async {
await AuthService().logout();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
},
icon: const Icon(Icons.logout, size: 18),
label: const Text('Abmelden', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: MeloTheme.rot,
side: const BorderSide(color: MeloTheme.rot),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
const SizedBox(height: 24),
],
),
),
const MiniPlayer(),
const SizedBox(height: 8),
],
);
}
Widget _mehrKachel({
required IconData icon,
required String titel,
required String untertitel,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.dunkel2),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: MeloTheme.rot, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
untertitel,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
],
),
),
const Icon(Icons.chevron_right, color: MeloTheme.textSekundaer, size: 20),
],
),
),
),
),
);
}
Widget _sektionHeader(String titel) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Text(
titel,
style: const TextStyle(
color: MeloTheme.textSekundaer,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
),
),
);
}
Widget _infoZeile(String label, String wert) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
label,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 13),
),
const Spacer(),
Flexible(
child: Text(
wert,
textAlign: TextAlign.right,
style: const TextStyle(color: Colors.white70, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
// ─── Einstellungen ──────────────────────────────────
Future<void> _oeffneEinstellungen() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
if (!mounted || result == null) return;
switch (result) {
case 'server':
_zeigeServerBrowser();
break;
case 'scanner':
_scanMusik();
break;
case 'logout':
await AuthService().logout();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
break;
}
}
// ─── NavigationBar ──────────────────────────────────
Widget _bottomNav() {
return Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.dunkel1)),
),
child: NavigationBar(
backgroundColor: MeloTheme.schwarz,
indicatorColor: MeloTheme.rot.withValues(alpha: 0.25),
selectedIndex: _aktiverTab,
onDestinationSelected: (i) {
if (i == 2) {
// ▶️ Jetzt läuft: Fullscreen öffnen, wenn etwas spielt
if (PlayerService().aktuellerSong != null) {
_oeffneNowPlaying();
return;
}
setState(() => _aktiverTab = 2);
return;
}
setState(() => _aktiverTab = i);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined, size: 22), selectedIcon: Icon(Icons.home, size: 22), label: 'Start'),
NavigationDestination(icon: Icon(Icons.library_music_outlined, size: 22), selectedIcon: Icon(Icons.library_music, size: 22), label: 'Bibliothek'),
NavigationDestination(icon: Icon(Icons.play_circle_outline, size: 22), selectedIcon: Icon(Icons.play_circle_fill, size: 22), label: 'Jetzt läuft'),
NavigationDestination(icon: Icon(Icons.cloud_outlined, size: 22), selectedIcon: Icon(Icons.cloud, size: 22), label: 'Cloud'),
NavigationDestination(icon: Icon(Icons.more_horiz, size: 22), label: 'Mehr'),
],
),
);
}
// ─── Songliste ──────────────────────────────────────
Widget _songListe({List<Song>? quelle, String titel = '📂 Alle Songs'}) {
final songs = quelle ?? _vm.gefilterteSongs;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titel, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
Row(children: [
Text('${songs.length} Titel${_vm.aktiveTags.isNotEmpty ? ' (gefiltert)' : ''}', style: TextStyle(fontSize: 12, color: MeloTheme.rot)),
const SizedBox(width: 8),
GestureDetector(
onTap: _scanMusik,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: MeloTheme.dunkel2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 12, color: MeloTheme.rot),
SizedBox(width: 4),
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.rot)),
],
),
),
),
]),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
itemCount: songs.length,
itemBuilder: (_, i) => SongTile(
song: songs[i],
istFavorit: songs[i].id != null && _vm.favoritenIds.contains(songs[i].id),
onFavoriteToggle: _vm.favoritenUmschalten,
onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist,
onErneutHerunterladen: _erneutHerunterladen,
onSpieleAlsNaechstes: (song) {
_vm.spieleAlsNaechstes(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('▶ Als Nächstes: ${song.titel}')),
);
},
onAmEndeHinzufuegen: (song) {
_vm.amEndeHinzufuegen(song);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('→ Am Ende hinzugefügt: ${song.titel}')),
);
},
),
),
),
],
);
}
/// Hilfs-Widget für Such-Modus-Chips
Widget _suchChip(String label, bool aktiv, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Text(label, style: TextStyle(fontSize: 11, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
),
);
}
/// Karte „Jahres-Recap“ öffnet den Wrapped-artigen Recap-Screen
Widget _recapKarte() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RecapScreen()),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3A0000), Color(0xFF1A0000), Color(0xFF0D0D0D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.45)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: MeloTheme.rot.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text('🎧', style: TextStyle(fontSize: 20)),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jahres-Recap ${DateTime.now().year}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 2),
const Text(
'Deine Top-Songs, Künstler & Hörzeit wie Spotify Wrapped',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
),
],
),
),
const Icon(Icons.chevron_right, color: MeloTheme.rot, size: 22),
],
),
),
),
);
}
/// Banner "💌 Seit 2008" erscheint nach 10 Playbacks
Widget _botschaftBanner() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF2A0000), Color(0xFF1A0000)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Text('💌', style: TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Seit 2008',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
Text('Danke, dass du immer da bist ♥',
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
),
),
GestureDetector(
onTap: _vm.botschaftAusblenden,
child: const Icon(Icons.close, size: 16, color: MeloTheme.textSekundaer),
),
],
),
),
);
}
}