From fdfc16680711802c693737a2de03ded2ab4e7a84 Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 5 Aug 2026 07:57:01 +0200 Subject: [PATCH] =?UTF-8?q?v2.51.2=20=E2=80=94=205-Tab-Navigation=20(Recht?= =?UTF-8?q?sh=C3=A4nder)=20+=20Musikserver-Status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- lib/screens/home_screen.dart | 1078 ++++++++++++++++++++++++++++++---- lib/widgets/melo_header.dart | 132 ++++- 2 files changed, 1098 insertions(+), 112 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5f1f2ff..0229ef4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,12 +9,16 @@ 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'; @@ -28,15 +32,27 @@ class MeloHome extends StatefulWidget { class _MeloHomeState extends State { 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). - // Der CloudService wechselt 'Verbinde...' → 'Verbunden' und benachrichtigt - // Listener — der CloudScreen zeigt den Status direkt vom Service an. _cloud.verbinde(); } @@ -46,6 +62,32 @@ class _MeloHomeState extends State { 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( + 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 _zeigeSuche() async { final controller = TextEditingController(); String modus = 'Alle'; @@ -191,7 +233,7 @@ class _MeloHomeState extends State { context: context, builder: (ctx) => AlertDialog( backgroundColor: MeloTheme.dunkel1, - title: const Text('🌐 Navidrome verbinden', style: TextStyle(color: Colors.white, fontSize: 15)), + title: const Text('🌐 Musikserver verbinden', style: TextStyle(color: Colors.white, fontSize: 15)), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -229,6 +271,80 @@ class _MeloHomeState extends State { }); } + /// Detail-Dialog zum ☁️ Musikserver-Status (Feature: Umbenennung von + /// „Navidrome verbinden“ → „Musikserver“). + Future _zeigeMusikserverDetail() async { + final verbunden = _vm.navidrome.istVerbunden; + await showDialog( + 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) { @@ -270,9 +386,29 @@ class _MeloHomeState extends State { } } - Future _zeigeDownloadDialog() async { - // Statt Dialog → zum Download-Tab wechseln - setState(() => _aktiverTab = 1); + 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 _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 @@ -287,54 +423,19 @@ class _MeloHomeState extends State { ); } - final gesamtMB = _vm.songs.isEmpty ? '0' - : (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0); - final gesamtMin = _vm.songs.isEmpty ? 0 - : (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(); - return Scaffold( backgroundColor: MeloTheme.schwarz, body: SafeArea( - child: _aktiverTab == 1 - ? DownloadScreen( - downloader: _vm.downloader, - onSongsChanged: _vm.ladeSongs, - ) - : _aktiverTab == 4 - ? CloudScreen( + child: switch (_aktiverTab) { + 1 => _bibliothekTab(), + 2 => _jetztLaeuftTab(), + 3 => CloudScreen( cloud: _cloud, onZurueck: () => setState(() => _aktiverTab = 0), - ) - : Column( - children: [ - MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser), - // ─── EIN/AUS: RecentWidget (Zuletzt gehört) ─── - // Entferne die Kommentarzeichen um RecentWidget zu aktivieren: - // RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong), - StatistikCard( - anzahlSongs: _vm.songs.length, - gesamtMB: gesamtMB, - gesamtMin: gesamtMin, - anzahlFavoriten: _vm.favoritenIds.length, ), - // ─── Jahres-Recap (wie Spotify Wrapped) ─── - _recapKarte(), - // ─── EIN/AUS: Hidden Message "Seit 2008" ─── - // Entferne die Kommentarzeichen um die Botschaft zu aktivieren: - if (_vm.zeigeBotschaft) _botschaftBanner(), - TagLeiste( - tags: _vm.tags, - aktiveTags: _vm.aktiveTags, - onTagToggled: _vm.toggleTag, - ), - // ─── EIN/AUS: TagStatsWidget (Tag-Counts) ─── - // Entferne die Kommentarzeichen um TagStatsWidget zu aktivieren: - // TagStatsWidget(tagCounts: _vm.tagCounts), - Expanded(child: _songListe()), - const MiniPlayer(), - const SizedBox(height: 8), - ], - ), + 4 => _mehrTab(), + _ => _startTab(), + }, ), bottomNavigationBar: _bottomNav(), ); @@ -342,8 +443,823 @@ class _MeloHomeState extends State { ); } - Widget _songListe() { - final songs = _vm.gefilterteSongs; + // ─── 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 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> _gruppiereNach(String Function(Song) schluessel) { + final gruppen = >{}; + 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> 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>( + 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 _zeigePlaylistSongs(Playlist pl) async { + final songs = await _vm.playlists.songs(pl.id!); + if (!mounted) return; + showDialog( + 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 _oeffneEinstellungen() async { + final result = await Navigator.push( + 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? quelle, String titel = '📂 Alle Songs'}) { + final songs = quelle ?? _vm.gefilterteSongs; return Column( children: [ Padding( @@ -351,7 +1267,7 @@ class _MeloHomeState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('📂 Alle Songs', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), + 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), @@ -388,6 +1304,7 @@ class _MeloHomeState extends State { onPlay: _vm.spieleSong, onMetadataChanged: _vm.ladeSongs, onAddToPlaylist: _zeigeAddToPlaylist, + onErneutHerunterladen: _erneutHerunterladen, onSpieleAlsNaechstes: (song) { _vm.spieleAlsNaechstes(song); ScaffoldMessenger.of(context).showSnackBar( @@ -407,65 +1324,6 @@ class _MeloHomeState extends State { ); } - Future _oeffneEinstellungen() async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsScreen()), - ); - if (!mounted || result == null) return; - - switch (result) { - case 'server': - _zeigeServerBrowser(); - break; - case 'logout': - await AuthService().logout(); - if (mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const LoginScreen()), - ); - } - break; - } - } - - Widget _bottomNav() { - return Container( - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: MeloTheme.dunkel1)), - ), - child: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - backgroundColor: MeloTheme.schwarz, - selectedItemColor: MeloTheme.rot, - unselectedItemColor: MeloTheme.textSekundaer, - currentIndex: _aktiverTab, - onTap: (i) { - if (i == 5) { - // Einstellungen als Screen öffnen - _oeffneEinstellungen(); - return; - } - setState(() => _aktiverTab = i); - // Zurück zu Musik → Filter zurücksetzen wenn Favoriten aktiv - if (i == 0 && _vm.aktiveTags.contains('★ Favoriten')) { - _vm.aktiveTags.clear(); - } else if (i == 3) { - _vm.aktiveTags = {'★ Favoriten'}; - } - }, - items: const [ - BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'), - BottomNavigationBarItem(icon: Icon(Icons.add_circle, size: 22), label: 'Lied +'), - BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'), - BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'), - BottomNavigationBarItem(icon: Icon(Icons.cloud, size: 22), label: 'Cloud'), - BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'), - ], - ), - ); - } - /// Hilfs-Widget für Such-Modus-Chips Widget _suchChip(String label, bool aktiv, VoidCallback onTap) { return GestureDetector( diff --git a/lib/widgets/melo_header.dart b/lib/widgets/melo_header.dart index 8af287b..64c60a1 100644 --- a/lib/widgets/melo_header.dart +++ b/lib/widgets/melo_header.dart @@ -7,7 +7,21 @@ class MeloHeader extends StatelessWidget { final VoidCallback? onServer; final VoidCallback? onSettings; - const MeloHeader({super.key, required this.onDownload, required this.onSearch, this.onServer, this.onSettings}); + /// Musikserver-Status (☁️ Musikserver + Statuspunkt): + /// `null` = kein Chip (alter Cloud-Button wird gezeigt). + final bool? serverVerbunden; + /// true = Sync läuft → Statuspunkt pulsiert. + final bool? serverSyncLaeuft; + + const MeloHeader({ + super.key, + required this.onDownload, + required this.onSearch, + this.onServer, + this.onSettings, + this.serverVerbunden, + this.serverSyncLaeuft, + }); @override Widget build(BuildContext context) { @@ -33,7 +47,14 @@ class MeloHeader extends StatelessWidget { ], ), Row(children: [ - if (onServer != null) ...[ + if (serverVerbunden != null) ...[ + _ServerStatusChip( + verbunden: serverVerbunden!, + syncLaeuft: serverSyncLaeuft ?? false, + onTap: onServer, + ), + const SizedBox(width: 8), + ] else if (onServer != null) ...[ _btn(Icons.cloud, onServer!), const SizedBox(width: 8), ], @@ -86,3 +107,110 @@ class MeloHeader extends StatelessWidget { ); } } + +/// Kompakter „☁️ Musikserver“-Chip mit Statuspunkt. +/// Grün = verbunden, Rot = offline, pulsierend = Sync läuft. +class _ServerStatusChip extends StatefulWidget { + final bool verbunden; + final bool syncLaeuft; + final VoidCallback? onTap; + + const _ServerStatusChip({ + required this.verbunden, + required this.syncLaeuft, + this.onTap, + }); + + @override + State<_ServerStatusChip> createState() => _ServerStatusChipState(); +} + +class _ServerStatusChipState extends State<_ServerStatusChip> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + if (widget.syncLaeuft) _controller.repeat(reverse: true); + } + + @override + void didUpdateWidget(covariant _ServerStatusChip oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.syncLaeuft && !oldWidget.syncLaeuft) { + _controller.repeat(reverse: true); + } else if (!widget.syncLaeuft && oldWidget.syncLaeuft) { + _controller.stop(); + _controller.value = 0; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final farbe = widget.verbunden + ? const Color(0xFF4CAF50) + : const Color(0xFFEF5350); + return GestureDetector( + onTap: widget.onTap, + child: Container( + height: 40, + 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: [ + const Text('☁️', style: TextStyle(fontSize: 13)), + const SizedBox(width: 6), + const Text( + 'Musikserver', + style: TextStyle(fontSize: 11, color: Colors.white70, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 6), + AnimatedBuilder( + animation: _controller, + builder: (_, child) { + // Pulsierende Größe während des Syncs + final skala = widget.syncLaeuft + ? 0.7 + (0.5 * _controller.value) + : 1.0; + return Transform.scale( + scale: skala, + child: child, + ); + }, + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: farbe, + boxShadow: [ + BoxShadow( + color: farbe.withValues(alpha: 0.6), + blurRadius: widget.syncLaeuft ? 10 : 5, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +}