diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 1d547b8..b5521d4 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -139,6 +139,18 @@ class DbHelper { } } + /// Die letzten [anzahl] abgespielten Songs + Future> letzteWiedergaben({int anzahl = 5}) async { + final d = await db; + final rows = await d.rawQuery(''' + SELECT s.* FROM songs s + JOIN wiedergabe_verlauf w ON s.id = w.song_id + ORDER BY w.zuletzt_abgespielt DESC + LIMIT ? + ''', [anzahl]); + return rows.map((r) => Song.fromMap(r)).toList(); + } + // ─── Tags ──────────────────────────────────────── Future tagErstellen(String name, {String? icon, String? farbe}) async { diff --git a/lib/models/playlist.dart b/lib/models/playlist.dart new file mode 100644 index 0000000..4521dee --- /dev/null +++ b/lib/models/playlist.dart @@ -0,0 +1,26 @@ +class Playlist { + final int? id; + final String name; + final String erstelltAm; + int songCount; + + Playlist({ + this.id, + required this.name, + String? erstelltAm, + this.songCount = 0, + }) : erstelltAm = erstelltAm ?? DateTime.now().toIso8601String(); + + Map toMap() => { + 'id': id, + 'name': name, + 'erstellt_am': erstelltAm, + }; + + factory Playlist.fromMap(Map m, {int songCount = 0}) => Playlist( + id: m['id'] as int?, + name: m['name'] as String, + erstelltAm: m['erstellt_am'] as String?, + songCount: songCount, + ); +} diff --git a/lib/models/tag.dart b/lib/models/tag.dart index d49962c..f503842 100644 --- a/lib/models/tag.dart +++ b/lib/models/tag.dart @@ -19,4 +19,8 @@ class Tag { icon: m['icon'] as String?, farbeHex: m['farbe_hex'] as String?, ); -} + + Map toDisplay() => { + 'name': name, + 'icon': icon ?? '', + };} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0ea28f8..3bef5ff 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,17 +1,18 @@ import 'package:flutter/material.dart'; import 'dart:async'; -import '../database/db_helper.dart'; -import '../services/player_service.dart'; -import '../services/musik_scanner.dart'; -import '../services/favoriten_service.dart'; -import '../services/download_service.dart'; +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 '../widgets/tag_stats_widget.dart'; +import '../widgets/navidrome_browser.dart'; +import '../widgets/playlist_sheet.dart'; class MeloHome extends StatefulWidget { const MeloHome({super.key}); @@ -21,104 +22,91 @@ class MeloHome extends StatefulWidget { } class _MeloHomeState extends State { - final PlayerService _player = PlayerService(); - final DbHelper _db = DbHelper(); - final FavoritenService _favoriten = FavoritenService(); - final MusikScanner _scanner = MusikScanner(); - final DownloadService _downloader = DownloadService(); - - List _songs = []; - String _aktiverTag = 'Alle'; - bool _ladt = true; - Set _favoritenIds = {}; - - final List> _beispielTags = [ - {'name': 'Alle', 'icon': ''}, - {'name': '❤️ Für uns', 'icon': '❤️'}, - {'name': 'Nightcore', 'icon': '⚡'}, - {'name': 'Traurig', 'icon': '😢'}, - {'name': 'Party', 'icon': '🎉'}, - {'name': 'Mitsingen', 'icon': '🎤'}, - {'name': '2000er', 'icon': '📀'}, - ]; + final MeloHomeViewModel _vm = MeloHomeViewModel(); + int _aktiverTab = 0; @override void initState() { super.initState(); - _ladeSongs(); + _vm.ladeSongs(); } - List get _gefilterteSongs { - if (_aktiverTag == 'Alle') return _songs; - return _songs.where((s) => - s.titel.contains(_aktiverTag) || - s.kuenstler.contains(_aktiverTag) - ).toList(); - } - - Future _ladeSongs() async { - setState(() => _ladt = true); - var songs = await _db.alleSongs(); - - if (songs.isEmpty) { - final beispiele = [ - Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', - dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true), - Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey', - dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true), - Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge', - dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true), - Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast', - dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true), - Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe', - dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true), - ]; - await _db.songsEinfuegen(beispiele); - songs = await _db.alleSongs(); - } - - final favoritenSet = await _favoriten.favoritenIds(); - - if (mounted) { - setState(() { - _songs = songs; - _favoritenIds = favoritenSet; - _ladt = false; - }); - } + @override + void dispose() { + _vm.dispose(); + super.dispose(); } Future _zeigeSuche() async { final controller = TextEditingController(); + String modus = 'Alle'; final ergebnis = await showDialog( context: context, - builder: (ctx) => AlertDialog( - backgroundColor: MeloTheme.dunkel1, - title: const Text('🔍 Song suchen', style: TextStyle(color: Colors.white, fontSize: 18)), - content: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - hintText: 'Titel oder Künstler...', - hintStyle: TextStyle(color: Colors.grey), - border: OutlineInputBorder(), + 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)), + ), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Suchen', style: TextStyle(color: MeloTheme.rot)), - ), - ], ), ); if (ergebnis == null || ergebnis.isEmpty) return; - final gefiltert = _songs.where((s) => - s.titel.toLowerCase().contains(ergebnis.toLowerCase()) || - s.kuenstler.toLowerCase().contains(ergebnis.toLowerCase()) - ).toList(); + + final teile = ergebnis.split('|'); + final suchtext = teile[0].toLowerCase(); + final suchModus = teile.length > 1 ? teile[1] : 'Alle'; + if (suchtext.isEmpty) return; + + 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 _vm.tags.any((t) => + t['name'] != 'Alle' && + t['name']!.toLowerCase().contains(suchtext) && + '${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); + } + // Alle + if (s.titel.toLowerCase().contains(suchtext)) return true; + if (s.kuenstler.toLowerCase().contains(suchtext)) return true; + return _vm.tags.any((t) => + t['name'] != 'Alle' && + t['name']!.toLowerCase().contains(suchtext) && + '${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); + }).toList(); if (!mounted) return; showDialog( context: context, @@ -143,7 +131,7 @@ class _MeloHomeState extends State { ), 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); _spieleSong(gefiltert[i]); }, + onTap: () { Navigator.pop(ctx); _vm.spieleSong(gefiltert[i]); }, ), ), ), @@ -153,7 +141,7 @@ class _MeloHomeState extends State { } Future _scanMusik() async { - final erlaubt = await _scanner.frageSpeicherZugriff(); + final erlaubt = await _vm.scanner.frageSpeicherZugriff(); if (!erlaubt) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -162,12 +150,89 @@ class _MeloHomeState extends State { } return; } - await _scanner.scanneMusikOrdner(); + await _vm.scanner.scanneMusikOrdner(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Scannen fertig: ${_scanner.anzahlNeueSongs} neue Songs gefunden')), + SnackBar(content: Text('Scannen fertig: ${_vm.scanner.anzahlNeueSongs} neue Songs gefunden')), ); - await _ladeSongs(); + await _vm.ladeSongs(); + } + } + + void _zeigeServerBrowser() { + showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.schwarz, + isScrollControlled: true, + builder: (_) => SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Column( + children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: 8), + width: 40, height: 4, + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(2), + ), + ), + Expanded(child: NavidromeBrowser(vm: _vm)), + ], + ), + ), + ); + } + + void _zeigePlaylistSheet() { + showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.schwarz, + isScrollControlled: true, + builder: (_) => SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: PlaylistSheet(vm: _vm), + ), + ); + } + + 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( + 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}')), + ); + } } } @@ -212,13 +277,13 @@ class _MeloHomeState extends State { if (ctx.mounted) setDialogState(() {}); }); - _downloader.downloadVonUrl(url).then((song) { + _vm.downloader.downloadVonUrl(url).then((song) { timer?.cancel(); if (song != null && ctx.mounted) { setDialogState(() {}); Future.delayed(const Duration(milliseconds: 800), () { if (ctx.mounted) Navigator.pop(ctx); - _ladeSongs(); + _vm.ladeSongs(); }); } else if (ctx.mounted) { Future.delayed(const Duration(seconds: 3), () { @@ -227,9 +292,9 @@ class _MeloHomeState extends State { } }); - final fehler = _downloader.fehler; - final fortschritt = _downloader.fortschritt; - final statusText = fehler ?? _downloader.aktuellerTitel ?? 'Song wird heruntergeladen...'; + final fehler = _vm.downloader.fehler; + final fortschritt = _vm.downloader.fortschritt; + final statusText = fehler ?? _vm.downloader.aktuellerTitel ?? 'Song wird heruntergeladen...'; return AlertDialog( backgroundColor: MeloTheme.dunkel1, @@ -263,62 +328,63 @@ class _MeloHomeState extends State { ); } - void _spieleSong(Song song) { - _player.setWarteschlange(_songs, - startIndex: _songs.indexWhere((s) => s.id == song.id)); - _player.spiele(song); - } - - Future _favoritenUmschalten(Song song) async { - if (song.id == null) return; - await _favoriten.umschalten(song.id!); - final aktuelleIds = await _favoriten.favoritenIds(); - if (mounted) setState(() => _favoritenIds = aktuelleIds); - } - @override Widget build(BuildContext context) { - if (_ladt) { - return const Scaffold( - backgroundColor: MeloTheme.schwarz, - body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)), - ); - } + return ListenableBuilder( + listenable: _vm, + builder: (_, __) { + if (_vm.ladt) { + return const Scaffold( + backgroundColor: MeloTheme.schwarz, + body: Center(child: CircularProgressIndicator(color: MeloTheme.rot)), + ); + } - final gesamtMB = _songs.isEmpty ? '0' - : (_songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0); - final gesamtMin = _songs.isEmpty ? 0 - : (_songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(); + 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: Column( - children: [ - MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche), - StatistikCard( - anzahlSongs: _songs.length, - gesamtMB: gesamtMB, - gesamtMin: gesamtMin, - anzahlFavoriten: _favoritenIds.length, + return Scaffold( + backgroundColor: MeloTheme.schwarz, + body: SafeArea( + child: Column( + children: [ + MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche), + // ─── 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, + ), + // ─── 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), + ], ), - TagLeiste( - tags: _beispielTags, - aktiverTag: _aktiverTag, - onTagSelected: (tag) => setState(() => _aktiverTag = tag), - ), - Expanded(child: _songListe()), - const MiniPlayer(), - const SizedBox(height: 8), - ], - ), - ), - bottomNavigationBar: _bottomNav(), + ), + bottomNavigationBar: _bottomNav(), + ); + }, ); } Widget _songListe() { - final songs = _gefilterteSongs; + final songs = _vm.gefilterteSongs; return Column( children: [ Padding( @@ -328,7 +394,7 @@ class _MeloHomeState extends State { children: [ const Text('📂 Alle Songs', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), Row(children: [ - Text('${songs.length} Titel${_aktiverTag != 'Alle' ? ' (gefiltert)' : ''}', style: TextStyle(fontSize: 12, color: MeloTheme.rot)), + Text('${songs.length} Titel${_vm.aktiveTags.isNotEmpty ? ' (gefiltert)' : ''}', style: TextStyle(fontSize: 12, color: MeloTheme.rot)), const SizedBox(width: 8), GestureDetector( onTap: _scanMusik, @@ -358,10 +424,11 @@ class _MeloHomeState extends State { itemCount: songs.length, itemBuilder: (_, i) => SongTile( song: songs[i], - istFavorit: songs[i].id != null && _favoritenIds.contains(songs[i].id), - onFavoriteToggle: _favoritenUmschalten, - onPlay: _spieleSong, - onMetadataChanged: _ladeSongs, + istFavorit: songs[i].id != null && _vm.favoritenIds.contains(songs[i].id), + onFavoriteToggle: _vm.favoritenUmschalten, + onPlay: _vm.spieleSong, + onMetadataChanged: _vm.ladeSongs, + onAddToPlaylist: _zeigeAddToPlaylist, ), ), ), @@ -379,6 +446,12 @@ class _MeloHomeState extends State { backgroundColor: MeloTheme.schwarz, selectedItemColor: MeloTheme.rot, unselectedItemColor: MeloTheme.textSekundaer, + currentIndex: _aktiverTab, + onTap: (i) { + setState(() => _aktiverTab = i); + if (i == 2) _zeigePlaylistSheet(); // Tags-Tab → Playlists + if (i == 3) _zeigePlaylistSheet(); // Favoriten-Tab → Playlists + }, items: const [ BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'), BottomNavigationBarItem(icon: Icon(Icons.download, size: 22), label: 'Downloads'), @@ -389,4 +462,55 @@ class _MeloHomeState extends State { ), ); } + + /// 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)), + ), + ); + } + + /// 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), + ), + ], + ), + ), + ); + } } diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart new file mode 100644 index 0000000..9a5bdd0 --- /dev/null +++ b/lib/services/navidrome_service.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import '../models/song.dart'; +import '../database/db_helper.dart'; + +/// Ein Song aus der Subsonic-API +class SubsonicSong { + final String id; + final String titel; + final String kuenstler; + final String album; + final int dauerSekunden; + final int groesseBytes; + final String? coverId; + + const SubsonicSong({ + required this.id, + required this.titel, + this.kuenstler = '', + this.album = '', + this.dauerSekunden = 0, + this.groesseBytes = 0, + this.coverId, + }); + + factory SubsonicSong.fromJson(Map j) => SubsonicSong( + id: j['id'] as String, + titel: j['title'] as String? ?? 'Unbekannt', + kuenstler: j['artist'] as String? ?? '', + album: j['album'] as String? ?? '', + dauerSekunden: (j['duration'] as int?) ?? 0, + groesseBytes: (j['size'] as int?) ?? 0, + coverId: j['coverArt'] as String?, + ); +} + +/// Ein Album aus der Subsonic-API +class SubsonicAlbum { + final String id; + final String name; + final String? coverId; + final int songCount; + + const SubsonicAlbum({ + required this.id, + required this.name, + this.coverId, + this.songCount = 0, + }); + + factory SubsonicAlbum.fromJson(Map j) => SubsonicAlbum( + id: j['id'] as String, + name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt', + coverId: j['coverArt'] as String?, + songCount: (j['songCount'] as int?) ?? 0, + ); +} + +class NavidromeService { + final DbHelper _db = DbHelper(); + + String _serverUrl = ''; + String _user = ''; + String _password = ''; + String _salt = ''; + String? _token; + + static const _version = '1.16.1'; + static const _client = 'Melo'; + + void setCredentials(String url, String user, String password) { + _serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + _user = user; + _password = password; + _salt = DateTime.now().millisecondsSinceEpoch.toString(); + _token = md5.convert(utf8.encode(_password + _salt)).toString(); + } + + bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty; + + Uri _uri(String endpoint, [Map? extra]) { + final params = { + 'u': _user, + 't': _token!, + 's': _salt, + 'v': _version, + 'c': _client, + 'f': 'json', + }; + if (extra != null) params.addAll(extra); + return Uri.parse('$_serverUrl/rest/$endpoint').replace(queryParameters: params); + } + + /// Verbindung testen + Future ping() async { + try { + final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10)); + return r.statusCode == 200; + } catch (_) { + return false; + } + } + + /// Alle Alben abrufen + Future> getAlben({int anzahl = 50}) async { + final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final list = data['subsonic-response']?['albumList']?['album'] as List? ?? []; + return list.map((j) => SubsonicAlbum.fromJson(j)).toList(); + } + + /// Songs eines Albums abrufen + Future> getSongs(String albumId) async { + final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; + return songs.map((j) => SubsonicSong.fromJson(j)).toList(); + } + + /// Song-Stream-URL + Uri streamUrl(String songId) { + return _uri('stream.view', {'id': songId, 'format': 'raw'}); + } + + /// Cover-URL + Uri? coverUrl(String coverId) { + return _uri('getCoverArt.view', {'id': coverId}); + } + + /// Song von Navidrome runterladen und lokal speichern + Future downloadSong(SubsonicSong s) async { + try { + final dir = await getApplicationDocumentsDirectory(); + final musikDir = Directory('${dir.path}/music'); + if (!await musikDir.exists()) await musikDir.create(recursive: true); + + final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim(); + final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp3'; + final dateiPfad = '${musikDir.path}/$dateiName'; + + final file = File(dateiPfad); + if (await file.exists()) { + final localSong = await _db.songNachPfad(dateiPfad); + if (localSong != null) return localSong; + } + + // Stream + speichern + final uri = streamUrl(s.id); + final response = await http.Client().send(http.Request('GET', uri)); + final sink = file.openWrite(); + await for (final chunk in response.stream) { + sink.add(chunk); + } + await sink.flush(); + await sink.close(); + + final song = Song( + titel: s.titel, + kuenstler: s.kuenstler, + album: s.album, + dauerSekunden: s.dauerSekunden, + dateiPfad: dateiPfad, + groesseBytes: await file.length(), + istHeruntergeladen: true, + downloadQuelle: 'server', + ); + + // Dauer per just_audio ermitteln wenn Navidrome keine liefert + // (optional, für später) + + await _db.songEinfuegen(song); + return song; + } catch (e) { + debugPrint('Navidrome Download Fehler: $e'); + return null; + } + } +} diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 677ab5e..2d667d3 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -90,6 +90,7 @@ class PlayerService { void dispose() { _autoNextSub?.cancel(); _player?.dispose(); + _player = null; _songWechsel.close(); } } diff --git a/lib/services/playlist_service.dart b/lib/services/playlist_service.dart new file mode 100644 index 0000000..748898c --- /dev/null +++ b/lib/services/playlist_service.dart @@ -0,0 +1,40 @@ +import '../database/db_helper.dart'; +import '../models/song.dart'; +import '../models/playlist.dart'; + +class PlaylistService { + final DbHelper _db = DbHelper(); + + Future> allePlaylists() async { + final rows = await _db.allePlaylists(); + final listen = []; + for (final r in rows) { + final id = r['id'] as int; + final songs = await _db.songsDerPlaylist(id); + listen.add(Playlist.fromMap(r, songCount: songs.length)); + } + return listen; + } + + Future erstellen(String name) async { + return _db.playlistErstellen(name); + } + + Future> songs(int playlistId) async { + return _db.songsDerPlaylist(playlistId); + } + + Future songHinzufuegen(int playlistId, int songId) async { + final songs = await _db.songsDerPlaylist(playlistId); + await _db.songZurPlaylist(playlistId, songId, songs.length); + } + + Future songEntfernen(int playlistId, int songId) async { + await _db.songAusPlaylistEntfernen(playlistId, songId); + } + + Future istInPlaylist(int playlistId, int songId) async { + final songs = await _db.songsDerPlaylist(playlistId); + return songs.any((s) => s.id == songId); + } +} diff --git a/lib/viewmodels/melo_home_viewmodel.dart b/lib/viewmodels/melo_home_viewmodel.dart new file mode 100644 index 0000000..2529e2f --- /dev/null +++ b/lib/viewmodels/melo_home_viewmodel.dart @@ -0,0 +1,209 @@ +import 'package:flutter/foundation.dart'; +import '../database/db_helper.dart'; +import '../services/player_service.dart'; +import '../services/musik_scanner.dart'; +import '../services/favoriten_service.dart'; +import '../services/download_service.dart'; +import '../services/navidrome_service.dart'; +import '../services/playlist_service.dart'; +import '../models/song.dart'; +import '../models/tag.dart'; + +class MeloHomeViewModel extends ChangeNotifier { + final PlayerService player = PlayerService(); + final DbHelper db = DbHelper(); + final FavoritenService favoriten = FavoritenService(); + final MusikScanner scanner = MusikScanner(); + final DownloadService downloader = DownloadService(); + final NavidromeService navidrome = NavidromeService(); + final PlaylistService playlists = PlaylistService(); + + List songs = []; + Set aktiveTags = {}; + bool ladt = true; + Set favoritenIds = {}; + List> tags = []; + List letzteSongs = []; + Map tagCounts = {}; + bool zeigeBotschaft = false; + bool serverLadt = false; + List navidromeAlben = []; + + int _playCount = 0; + static const int _botschaftSchwellwert = 10; + + static const _defaultTags = [ + {'name': 'Alle', 'icon': ''}, + {'name': '❤️ Für uns', 'icon': '❤️'}, + {'name': 'Nightcore', 'icon': '⚡'}, + {'name': 'Traurig', 'icon': '😢'}, + {'name': 'Party', 'icon': '🎉'}, + {'name': 'Mitsingen', 'icon': '🎤'}, + {'name': '2000er', 'icon': '📀'}, + ]; + + List get gefilterteSongs { + if (aktiveTags.isEmpty) return songs; + return songs.where((s) => + aktiveTags.any((tag) => + s.titel.contains(tag) || s.kuenstler.contains(tag)) + ).toList(); + } + + Future ladeSongs() async { + ladt = true; + notifyListeners(); + + var alle = await db.alleSongs(); + + if (alle.isEmpty) { + final beispiele = [ + Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', + dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true), + Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey', + dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true), + Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge', + dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true), + Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast', + dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true), + Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe', + dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true), + ]; + await db.songsEinfuegen(beispiele); + alle = await db.alleSongs(); + } + + songs = alle; + favoritenIds = await favoriten.favoritenIds(); + tagCounts = _berechneTagCounts(); + letzteSongs = await db.letzteWiedergaben(); + await ladeTags(); + ladt = false; + notifyListeners(); + } + + Map _berechneTagCounts() { + final counts = {}; + for (final s in songs) { + for (final t in tags) { + final name = t['name']!; + if (name == 'Alle') continue; + if (s.titel.contains(name) || s.kuenstler.contains(name)) { + counts[name] = (counts[name] ?? 0) + 1; + } + } + } + return counts; + } + + Future ladeTags() async { + var dbTags = await db.alleTags(); + if (dbTags.isEmpty) { + for (final t in _defaultTags) { + if (t['name'] != 'Alle') { + await db.tagErstellen(t['name']!, icon: t['icon']); + } + } + dbTags = await db.alleTags(); + } + tags = [{'name': 'Alle', 'icon': ''}, ...dbTags.map((t) => t.toDisplay())]; + // Tag-Counts nachladen wenn Tags geladen + tagCounts = _berechneTagCounts(); + } + + void spieleSong(Song song) { + player.setWarteschlange(songs, + startIndex: songs.indexWhere((s) => s.id == song.id)); + player.spiele(song); + + // In Verlauf speichern + if (song.id != null) { + db.positionAktualisieren(song.id!, 0); + } + + // Play-Counter für Hidden Message + _playCount++; + if (_playCount >= _botschaftSchwellwert && !zeigeBotschaft) { + zeigeBotschaft = true; + notifyListeners(); + } + + // Letzte Songs aktualisieren + db.letzteWiedergaben().then((liste) { + letzteSongs = liste; + notifyListeners(); + }); + } + + /// Manuell die Botschaft "Seit 2008" anzeigen + void botschaftAnzeigen() { + zeigeBotschaft = true; + notifyListeners(); + } + + /// Botschaft ausblenden + void botschaftAusblenden() { + zeigeBotschaft = false; + notifyListeners(); + } + + void toggleTag(String tag) { + if (tag == 'Alle') { + aktiveTags.clear(); + } else if (aktiveTags.contains(tag)) { + aktiveTags.remove(tag); + } else { + aktiveTags.add(tag); + } + notifyListeners(); + } + + Future favoritenUmschalten(Song song) async { + if (song.id == null) return; + await favoriten.umschalten(song.id!); + favoritenIds = await favoriten.favoritenIds(); + notifyListeners(); + } + + // ─── Navidrome Server-Sync ───────────────────────── + + /// Navidrome-Zugangsdaten setzen + void verbindeNavidrome(String url, String user, String password) { + navidrome.setCredentials(url, user, password); + } + + /// Verbindung testen und Alben laden + Future ladeNavidromeAlben() async { + if (!navidrome.istVerbunden) return false; + serverLadt = true; + notifyListeners(); + final ok = await navidrome.ping(); + if (ok) { + navidromeAlben = await navidrome.getAlben(); + } + serverLadt = false; + notifyListeners(); + return ok; + } + + /// Songs eines Albums laden + Future> ladeAlbumSongs(String albumId) async { + return navidrome.getSongs(albumId); + } + + /// Song von Navidrome runterladen + Future downloadNavidromeSong(SubsonicSong s) async { + final song = await navidrome.downloadSong(s); + if (song != null) { + await ladeSongs(); + return true; + } + return false; + } + + @override + void dispose() { + player.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/melo_header.dart b/lib/widgets/melo_header.dart index 72fd608..7c4ef24 100644 --- a/lib/widgets/melo_header.dart +++ b/lib/widgets/melo_header.dart @@ -4,8 +4,9 @@ import '../utils/farb_theme.dart'; class MeloHeader extends StatelessWidget { final VoidCallback onDownload; final VoidCallback onSearch; + final VoidCallback? onServer; - const MeloHeader({super.key, required this.onDownload, required this.onSearch}); + const MeloHeader({super.key, required this.onDownload, required this.onSearch, this.onServer}); @override Widget build(BuildContext context) { @@ -28,6 +29,10 @@ class MeloHeader extends StatelessWidget { ], ), Row(children: [ + if (onServer != null) ...[ + _btn(Icons.cloud, onServer!), + const SizedBox(width: 8), + ], _btn(Icons.download, onDownload), const SizedBox(width: 8), _btn(Icons.search, onSearch), diff --git a/lib/widgets/navidrome_browser.dart b/lib/widgets/navidrome_browser.dart new file mode 100644 index 0000000..db63dce --- /dev/null +++ b/lib/widgets/navidrome_browser.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import '../services/navidrome_service.dart'; +import '../utils/farb_theme.dart'; + +/// Navidrome-Browser als Bottom-Sheet. +/// Manuell in home_screen.dart einbaubar. +class NavidromeBrowser extends StatefulWidget { + final dynamic vm; + const NavidromeBrowser({super.key, required this.vm}); + + @override + State createState() => _NavidromeBrowserState(); +} + +class _NavidromeBrowserState extends State { + String? _gewaehltesAlbum; + + @override + Widget build(BuildContext context) { + final vm = widget.vm; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + const Text('📡 Navidrome', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), + const Spacer(), + if (!vm.navidrome.istVerbunden) + _btn(context, 'Verbinden', () => _zeigeLoginDialog(context)) + else ...[ + Text(vm.navidrome.istVerbunden ? '✅' : '', style: const TextStyle(fontSize: 14)), + const SizedBox(width: 8), + _btn(context, 'Alben laden', () => vm.ladeNavidromeAlben().then((_) => setState(() {}))), + ], + ], + ), + const SizedBox(height: 12), + // Inhalt + if (!vm.navidrome.istVerbunden) + _platzhalter('Server-URL + Zugangsdaten eingeben') + else if (vm.serverLadt) + const Center(child: Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator(color: MeloTheme.rot, strokeWidth: 2), + )) + else if (_gewaehltesAlbum != null) + _albumSongListe(context, _gewaehltesAlbum!) + else if (vm.navidromeAlben.isEmpty) + _platzhalter('"Alben laden" um Musik zu sehen') + else + _albumListe(context, vm.navidromeAlben as List), + ], + ), + ); + } + + Widget _platzhalter(String text) { + return Expanded(child: Center( + child: Text(text, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + )); + } + + Widget _albumListe(BuildContext context, List alben) { + return Expanded( + child: Column( + children: [ + Text('${alben.length} Alben', style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: alben.length, + itemBuilder: (_, i) => GestureDetector( + onTap: () async { + setState(() => _gewaehltesAlbum = alben[i].id); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: MeloTheme.rot.withValues(alpha: 0.3), + ), + child: const Center(child: Text('💿', style: TextStyle(fontSize: 14))), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(alben[i].name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.white)), + Text('${alben[i].songCount} Songs', + style: const TextStyle(fontSize: 10, color: MeloTheme.textSekundaer)), + ], + ), + ), + const Icon(Icons.chevron_right, size: 16, color: MeloTheme.textSekundaer), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _albumSongListe(BuildContext context, String albumId) { + return FutureBuilder>( + future: widget.vm.ladeAlbumSongs(albumId), + builder: (_, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Expanded(child: Center( + child: CircularProgressIndicator(color: MeloTheme.rot, strokeWidth: 2), + )); + } + final songs = snap.data ?? []; + return Expanded( + child: Column( + children: [ + Row( + children: [ + GestureDetector( + onTap: () => setState(() => _gewaehltesAlbum = null), + child: const Icon(Icons.arrow_back, size: 18, color: MeloTheme.rot), + ), + const SizedBox(width: 8), + Text('${songs.length} Songs', + style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + ], + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: songs.length, + itemBuilder: (_, i) => _songTile(context, songs[i]), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _songTile(BuildContext context, SubsonicSong s) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: MeloTheme.rot.withValues(alpha: 0.3), + ), + child: const Center(child: Text('♪', style: TextStyle(fontSize: 14))), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.titel, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.white), + overflow: TextOverflow.ellipsis), + Text('${s.kuenstler} · ${s.dauerFormatiert}', + style: const TextStyle(fontSize: 10, color: MeloTheme.textSekundaer)), + ], + ), + ), + GestureDetector( + onTap: () => widget.vm.downloadNavidromeSong(s).then((_) => setState(() {})), + child: const Icon(Icons.download, size: 18, color: MeloTheme.rot), + ), + ], + ), + ); + } + + Widget _btn(BuildContext context, String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: MeloTheme.dunkel2), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 11, color: MeloTheme.rot)), + ), + ); + } + + void _zeigeLoginDialog(BuildContext context) { + final urlCtrl = TextEditingController(); + final userCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('🌐 Navidrome', style: TextStyle(color: Colors.white, fontSize: 16)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://musik.baka-net.de', + labelStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + const SizedBox(height: 8), + TextField(controller: userCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Benutzer', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + const SizedBox(height: 8), + TextField(controller: passCtrl, style: const TextStyle(color: Colors.white), obscureText: true, + decoration: const InputDecoration(labelText: 'Passwort', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: () { + widget.vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text); + Navigator.pop(ctx); + widget.vm.ladeNavidromeAlben().then((_) => setState(() {})); + }, + child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)), + ), + ], + ), + ); + } +} + +extension on SubsonicSong { + String get dauerFormatiert { + final min = dauerSekunden ~/ 60; + final sek = dauerSekunden % 60; + return '$min:${sek.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/widgets/playlist_sheet.dart b/lib/widgets/playlist_sheet.dart new file mode 100644 index 0000000..44f5820 --- /dev/null +++ b/lib/widgets/playlist_sheet.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import '../models/song.dart'; +import '../models/playlist.dart'; +import '../utils/farb_theme.dart'; + +/// Bottom-Sheet zum Durchstöbern von Playlists. +class PlaylistSheet extends StatefulWidget { + final dynamic vm; + const PlaylistSheet({super.key, required this.vm}); + + @override + State createState() => _PlaylistSheetState(); +} + +class _PlaylistSheetState extends State { + List _playlists = []; + bool _ladt = true; + + @override + void initState() { + super.initState(); + _laden(); + } + + Future _laden() async { + _ladt = true; + setState(() {}); + _playlists = await widget.vm.playlists.allePlaylists(); + _ladt = false; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 12), + // Header + Row( + children: [ + const Icon(Icons.queue_music, size: 18, color: Colors.white), + const SizedBox(width: 6), + const Text('Playlists', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), + const Spacer(), + _btn('+ Neu', _neuePlaylist), + ], + ), + const SizedBox(height: 12), + if (_ladt) + const Expanded(child: Center(child: CircularProgressIndicator(color: MeloTheme.rot, strokeWidth: 2))) + else if (_playlists.isEmpty) + const Expanded(child: Center(child: Text('Noch keine Playlists\nTippe oben auf "+ Neu"', + textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)))) + else + Expanded(child: _listView()), + ], + ), + ); + } + + Widget _listView() { + return RefreshIndicator( + color: MeloTheme.rot, + onRefresh: _laden, + child: ListView.builder( + itemCount: _playlists.length, + itemBuilder: (_, i) => _playlistTile(_playlists[i]), + ), + ); + } + + Widget _playlistTile(Playlist p) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: Container( + width: 40, height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: MeloTheme.rot.withValues(alpha: 0.3), + ), + child: const Center(child: Text('🎵', style: TextStyle(fontSize: 16))), + ), + title: Text(p.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), + subtitle: Text('${p.songCount} Songs · ${p.erstelltAm.substring(0, 10)}', + style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)), + trailing: const Icon(Icons.chevron_right, size: 18, color: MeloTheme.textSekundaer), + onTap: () => _playlistOffnen(context, p), + ), + ); + } + + void _playlistOffnen(BuildContext context, Playlist p) async { + final songs = await widget.vm.playlists.songs(p.id!); + if (!context.mounted) return; + showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.schwarz, + isScrollControlled: true, + builder: (_) => SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: _PlaylistDetail(p: p, songs: songs, vm: widget.vm, onChanged: _laden), + ), + ); + } + + void _neuePlaylist() { + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('Neue Playlist', style: TextStyle(color: Colors.white, fontSize: 16)), + content: TextField( + controller: ctrl, + autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + hintText: 'Name...', + hintStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + if (ctrl.text.trim().isEmpty) return; + await widget.vm.playlists.erstellen(ctrl.text.trim()); + if (ctx.mounted) Navigator.pop(ctx); + _laden(); + }, + child: const Text('Erstellen', style: TextStyle(color: MeloTheme.rot)), + ), + ], + ), + ); + } + + Widget _btn(String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: MeloTheme.dunkel2), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 11, color: MeloTheme.rot)), + ), + ); + } +} + +/// Detailansicht einer Playlist mit Songs +class _PlaylistDetail extends StatelessWidget { + final Playlist p; + final List songs; + final dynamic vm; + final VoidCallback onChanged; + + const _PlaylistDetail({ + required this.p, + required this.songs, + required this.vm, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon(Icons.arrow_back, size: 20, color: MeloTheme.rot), + ), + const SizedBox(width: 8), + Text(p.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), + const Spacer(), + Text('${songs.length} Songs', style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + ], + ), + const SizedBox(height: 12), + Expanded( + child: songs.isEmpty + ? const Center(child: Text('Playlist ist leer', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer))) + : ListView.builder( + itemCount: songs.length, + itemBuilder: (_, i) => _songTile(context, songs[i]), + ), + ), + ], + ), + ); + } + + Widget _songTile(BuildContext context, Song song) { + return Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: MeloTheme.rot.withValues(alpha: 0.3), + ), + child: const Center(child: Text('♪', style: TextStyle(fontSize: 14))), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () { Navigator.pop(context); vm.spieleSong(song); }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(song.titel, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.white), overflow: TextOverflow.ellipsis), + Text(song.kuenstler, style: const TextStyle(fontSize: 10, color: MeloTheme.textSekundaer)), + ], + ), + ), + ), + GestureDetector( + onTap: () async { + await vm.playlists.songEntfernen(p.id!, song.id!); + onChanged(); + if (context.mounted) Navigator.pop(context); + }, + child: const Icon(Icons.remove_circle_outline, size: 18, color: MeloTheme.textSekundaer), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/recent_widget.dart b/lib/widgets/recent_widget.dart new file mode 100644 index 0000000..b11bde3 --- /dev/null +++ b/lib/widgets/recent_widget.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import '../models/song.dart'; +import '../utils/farb_theme.dart'; + +/// Zeigt die letzten abgespielten Songs als horizontale Liste. +/// Manuell in home_screen.dart einbaubar: +/// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong) +class RecentWidget extends StatelessWidget { + final List songs; + final void Function(Song) onPlay; + + const RecentWidget({super.key, required this.songs, required this.onPlay}); + + @override + Widget build(BuildContext context) { + if (songs.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('🕐 Zuletzt gehört', + style: 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: () => onPlay(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)), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart index 351e05a..bde4a50 100644 --- a/lib/widgets/song_tile.dart +++ b/lib/widgets/song_tile.dart @@ -9,6 +9,7 @@ class SongTile extends StatelessWidget { final ValueChanged onFavoriteToggle; final ValueChanged onPlay; final VoidCallback onMetadataChanged; + final ValueChanged? onAddToPlaylist; const SongTile({ super.key, @@ -51,6 +52,15 @@ class SongTile extends StatelessWidget { child: Icon(Icons.edit, size: 14, color: MeloTheme.textSekundaer), ), ), + if (onAddToPlaylist != null) + InkWell( + borderRadius: BorderRadius.circular(50), + onTap: () => onAddToPlaylist!(song), + child: Padding( + padding: const EdgeInsets.all(6), + child: const Icon(Icons.playlist_add, size: 14, color: MeloTheme.textSekundaer), + ), + ), InkWell( borderRadius: BorderRadius.circular(50), onTap: () => onFavoriteToggle(song), diff --git a/lib/widgets/tag_leiste.dart b/lib/widgets/tag_leiste.dart index 32c28e2..b1c9e9f 100644 --- a/lib/widgets/tag_leiste.dart +++ b/lib/widgets/tag_leiste.dart @@ -3,14 +3,14 @@ import '../utils/farb_theme.dart'; class TagLeiste extends StatelessWidget { final List> tags; - final String aktiverTag; - final ValueChanged onTagSelected; + final Set aktiveTags; + final ValueChanged onTagToggled; const TagLeiste({ super.key, required this.tags, - required this.aktiverTag, - required this.onTagSelected, + required this.aktiveTags, + required this.onTagToggled, }); @override @@ -24,7 +24,11 @@ class TagLeiste extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('🏷️ Tags', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), - Text('+ Neu', style: TextStyle(fontSize: 12, color: MeloTheme.rot, fontWeight: FontWeight.w500)), + if (aktiveTags.isNotEmpty) + GestureDetector( + onTap: () => onTagToggled('Alle'), + child: Text('löschen', style: TextStyle(fontSize: 11, color: MeloTheme.rot.withValues(alpha: 0.7))), + ), ], ), ), @@ -34,14 +38,15 @@ class TagLeiste extends StatelessWidget { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 20), children: tags.map((tag) { - final aktiv = aktiverTag == tag['name']; + final name = tag['name']!; + final aktiv = aktiveTags.contains(name); return Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( - label: Text('${tag['icon']} ${tag['name']}', + label: Text('${tag['icon']} $name', style: TextStyle(fontSize: 13, color: aktiv ? Colors.white : MeloTheme.textSekundaer)), selected: aktiv, - onSelected: (_) => onTagSelected(tag['name']!), + onSelected: (_) => onTagToggled(name), selectedColor: MeloTheme.rot, backgroundColor: MeloTheme.dunkel1, side: BorderSide.none, diff --git a/lib/widgets/tag_stats_widget.dart b/lib/widgets/tag_stats_widget.dart new file mode 100644 index 0000000..5af333d --- /dev/null +++ b/lib/widgets/tag_stats_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../utils/farb_theme.dart'; + +/// Zeigt wie viele Songs pro Tag existieren. +/// Manuell in home_screen.dart einbaubar: +/// TagStatsWidget(tagCounts: _vm.tagCounts) +class TagStatsWidget extends StatelessWidget { + final Map tagCounts; + + const TagStatsWidget({super.key, required this.tagCounts}); + + @override + Widget build(BuildContext context) { + if (tagCounts.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: tagCounts.entries.map((e) { + final farbe = _tagFarbe(e.key); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: farbe.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: farbe.withValues(alpha: 0.3)), + ), + child: Text('${e.key} ${e.value}', + style: TextStyle(fontSize: 11, color: farbe, fontWeight: FontWeight.w500)), + ); + }).toList(), + ), + ); + } + + Color _tagFarbe(String name) { + switch (name) { + case '❤️ Für uns': return const Color(0xFFFF4444); + case 'Nightcore': return const Color(0xFFBB86FC); + case 'Traurig': return const Color(0xFF5C8DFF); + case 'Party': return const Color(0xFFFFB74D); + case 'Mitsingen': return const Color(0xFF69F0AE); + case '2000er': return const Color(0xFFFF80AB); + default: return MeloTheme.rot; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 0b2e93d..fc5693d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,10 @@ dependencies: # Einstellungen shared_preferences: ^2.3.5 + # HTTP (Navidrome Server-Sync) + http: ^1.2.0 + crypto: ^3.0.6 + # UI cupertino_icons: ^1.0.8