- Design-System: MeloDesignTokens ThemeExtension, Outfit-Font, Farben #0B0B10/#14141C/#1C1C26 - Akzentfarbe-User-Einstellung behalten (MeloTheme.akzentNotifier), Gradient aus Akzent - Startseite: Hero-Weiterhören-Karte mit Cover, Titel, Glow-Play-Button - 2-stufige Einstellungen: Standard + Expertenmodus (SharedPreferences Key 'experten_modus') - Komponenten: Pill-Buttons (radius 999), Card-Radius 20, Glow-Shadow, PressScale-Widget - Login: Gradient-Button mit Akzent-Glow - 199 Tests grün (194 bestehend + 5 neu): ThemeExtension, Expertenmodus, Akzent, PressScale, Hero - flutter analyze lib/ + test/ = 0 Issues - KEIN APK
1625 lines
58 KiB
Dart
1625 lines
58 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'dart:async';
|
||
import 'dart:io';
|
||
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/press_scale.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 '../services/sync_service.dart';
|
||
import '../services/realtime_sync_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> with WidgetsBindingObserver {
|
||
final RealtimeSyncService _realtimeSync = RealtimeSyncService();
|
||
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();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
_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();
|
||
// Realtime-Sync starten (SSE-Push, wenn Modus=Echtzeit)
|
||
_realtimeSync.starteWennAktiviert();
|
||
// Tipp auf die Sync-Abschluss-/Fehler-Notification → Cloud-Tab öffnen
|
||
SyncService.syncBenachrichtigungGetippt.addListener(_syncNotifGetippt);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
SyncService.syncBenachrichtigungGetippt.removeListener(_syncNotifGetippt);
|
||
_realtimeSync.stoppe();
|
||
_vm.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
super.didChangeAppLifecycleState(state);
|
||
switch (state) {
|
||
case AppLifecycleState.resumed:
|
||
_realtimeSync.fortsetzen();
|
||
case AppLifecycleState.paused:
|
||
case AppLifecycleState.detached:
|
||
case AppLifecycleState.inactive:
|
||
case AppLifecycleState.hidden:
|
||
_realtimeSync.pausiere();
|
||
}
|
||
}
|
||
|
||
/// Öffnet den Cloud-Tab, wenn die Sync-Notification getippt wurde.
|
||
void _syncNotifGetippt() {
|
||
if (!mounted) return;
|
||
setState(() => _aktiverTab = 3);
|
||
}
|
||
|
||
// ─── 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;
|
||
|
||
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);
|
||
});
|
||
}
|
||
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();
|
||
}
|
||
}
|
||
|
||
Future<void> _zeigeServerBrowser() async {
|
||
if (_vm.navidrome.istVerbunden) {
|
||
await _vm.navidrome.loescheZugangsdaten();
|
||
_vm.navidromeAlben.clear();
|
||
if (mounted) setState(() {});
|
||
return;
|
||
}
|
||
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();
|
||
});
|
||
}
|
||
|
||
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();
|
||
},
|
||
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() {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => DownloadScreen(
|
||
downloader: _vm.downloader,
|
||
onSongsChanged: _vm.ladeSongs,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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: Listenable.merge([_vm, _cloud, SyncService.laeuftNotifier]),
|
||
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: SyncService.laeuftNotifier.value ||
|
||
_cloud.status == CloudStatus.verbinde ||
|
||
_vm.serverLadt,
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
children: [
|
||
// ═══ Hero „Weiterhören"-Karte ═══
|
||
_weiterhoerenHeroKarte(),
|
||
// 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),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Hero „Weiterhören" – zeigt den aktuellen/last Song als große Card mit Glow
|
||
Widget _weiterhoerenHeroKarte() {
|
||
final aktuellerSong = PlayerService().aktuellerSong;
|
||
// Fallback: letzten gespielten Song verwenden
|
||
final song = aktuellerSong ?? (_vm.letzteSongs.isNotEmpty ? _vm.letzteSongs.first : null);
|
||
if (song == null) return const SizedBox.shrink();
|
||
|
||
final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync();
|
||
final akzent = MeloTheme.akzent;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
|
||
child: GestureDetector(
|
||
onTap: () => _oeffneNowPlaying(),
|
||
child: AnimatedScale(
|
||
scale: 1.0,
|
||
duration: const Duration(milliseconds: 120),
|
||
curve: Curves.easeOut,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(20),
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
akzent.withValues(alpha: 0.3),
|
||
MeloTheme.schwarz,
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
border: Border.all(
|
||
color: akzent.withValues(alpha: 0.35),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: akzent.withValues(alpha: 0.25),
|
||
blurRadius: 30,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Row(
|
||
children: [
|
||
// Cover
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
akzent,
|
||
Color.lerp(akzent, Colors.black, 0.5)!,
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
),
|
||
child: hatCover
|
||
? Image.file(File(song.coverPfad!), fit: BoxFit.cover)
|
||
: const Center(
|
||
child: Text('♪', style: TextStyle(fontSize: 28, color: Colors.white)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
// Infos
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'Weiterhören',
|
||
key: Key('weiterhoeren_label'),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: MeloTheme.textSekundaer,
|
||
letterSpacing: 0.5,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
song.titel,
|
||
key: const Key('weiterhoeren_titel'),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.white,
|
||
letterSpacing: -0.5,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
song.kuenstler,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: MeloTheme.textSekundaer,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Play-Button mit Glow
|
||
_playButton(akzent),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Press-Scale Play-Button mit Glow (Mikro-Interaktion)
|
||
Widget _playButton(Color akzent) {
|
||
return PressScale(
|
||
onTap: () => _oeffneNowPlaying(),
|
||
child: Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
gradient: LinearGradient(
|
||
colors: [akzent, Color.lerp(akzent, Colors.black, 0.4)!],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: akzent.withValues(alpha: 0.35),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 28),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// ⭐ 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(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 28, height: 28,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
MeloTheme.akzent.withValues(alpha: 0.4),
|
||
MeloTheme.akzent.withValues(alpha: 0.1),
|
||
],
|
||
),
|
||
),
|
||
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: [
|
||
_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(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 16, color: MeloTheme.akzent),
|
||
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.akzent : MeloTheme.dunkel1,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
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'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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(20),
|
||
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: Icon(Icons.folder_outlined, color: MeloTheme.akzent, 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(),
|
||
);
|
||
}
|
||
|
||
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(20),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(20),
|
||
onTap: () {
|
||
_vm.aktiveTags = {e.key};
|
||
setState(() => _bibSegment = 'Songs');
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.label_outline, color: MeloTheme.akzent, 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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
)),
|
||
],
|
||
);
|
||
}
|
||
|
||
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(20),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(20),
|
||
onTap: () => _zeigePlaylistSongs(pl),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.queue_music, color: MeloTheme.akzent, 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: Icon(Icons.music_note, color: MeloTheme.akzent, 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'))],
|
||
),
|
||
);
|
||
}
|
||
|
||
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: LinearGradient(
|
||
colors: [
|
||
MeloTheme.akzent.withValues(alpha: 0.25),
|
||
MeloTheme.akzent.withValues(alpha: 0.05),
|
||
],
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.4)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.add_circle_outline, color: MeloTheme.akzent, size: 22),
|
||
const SizedBox(width: 12),
|
||
const 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.akzent, size: 20),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _songListe(quelle: downloads, titel: '📂 Downloads'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ─── Tab: ▶️ Jetzt läuft ────────────────────────────
|
||
|
||
Widget _jetztLaeuftTab() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.play_circle_outline, size: 64, color: MeloTheme.dunkel2.withValues(alpha: 0.5)),
|
||
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),
|
||
FilledButton(
|
||
onPressed: () => setState(() => _aktiverTab = 1),
|
||
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(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_infoZeile('Version', '2.56.0'),
|
||
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||
_infoZeile('Theme', 'Melo Dark Fusion'),
|
||
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.akzent,
|
||
side: BorderSide(color: MeloTheme.akzent),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||
),
|
||
),
|
||
),
|
||
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(20),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.dunkel2),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.dunkel2,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Icon(icon, color: MeloTheme.akzent, 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: BoxDecoration(
|
||
border: Border(top: BorderSide(color: MeloTheme.oberflaeche, width: 0)),
|
||
),
|
||
child: NavigationBar(
|
||
backgroundColor: MeloTheme.schwarz,
|
||
indicatorColor: MeloTheme.akzent.withValues(alpha: 0.25),
|
||
selectedIndex: _aktiverTab,
|
||
onDestinationSelected: (i) {
|
||
if (i == 2) {
|
||
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.akzent)),
|
||
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: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.refresh, size: 12, color: MeloTheme.akzent),
|
||
const SizedBox(width: 4),
|
||
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.akzent)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
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}')),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _suchChip(String label, bool aktiv, VoidCallback onTap) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: aktiv ? MeloTheme.akzent : MeloTheme.dunkel2,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(label, style: TextStyle(fontSize: 11, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
|
||
),
|
||
);
|
||
}
|
||
|
||
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: LinearGradient(
|
||
colors: [
|
||
MeloTheme.akzent.withValues(alpha: 0.2),
|
||
MeloTheme.akzent.withValues(alpha: 0.05),
|
||
MeloTheme.schwarz,
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.35)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: MeloTheme.akzent.withValues(alpha: 0.18),
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: const Center(
|
||
child: Text('🎧', style: TextStyle(fontSize: 20)),
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Recap ${DateTime.now().year}',
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
const Text(
|
||
'Woche · Monat · Jahr – deine Top-Songs, Künstler & Hörzeit',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: MeloTheme.akzent, size: 22),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _botschaftBanner() {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
MeloTheme.akzent.withValues(alpha: 0.2),
|
||
MeloTheme.akzent.withValues(alpha: 0.05),
|
||
],
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: MeloTheme.akzent.withValues(alpha: 0.35)),
|
||
),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|