diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index a8a66fe..5cfeabd 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -96,6 +96,18 @@ class DbHelper { ); } + /// Löscht fehlerhafte Einträge ohne Dateipfad + Future alteDummiesLoeschen() async { + final d = await db; + await d.delete('songs', where: "datei_pfad = '' OR datei_pfad IS NULL"); + } + + /// Einzelnen Song löschen + Future loeschSong(int id) async { + final d = await db; + await d.delete('songs', where: 'id = ?', whereArgs: [id]); + } + // ─── Songs ────────────────────────────────────── Future songEinfuegen(Song song) async { @@ -193,6 +205,34 @@ class DbHelper { return rows.map((r) => Tag.fromMap(r)).toList(); } + Future songTagEntfernen(int songId, int tagId) async { + final d = await db; + await d.delete('song_tags', + where: 'song_id = ? AND tag_id = ?', + whereArgs: [songId, tagId]); + } + + Future> songIdsFuerTag(String tagName) async { + final d = await db; + final rows = await d.rawQuery(''' + SELECT st.song_id FROM song_tags st + JOIN tags t ON t.id = st.tag_id + WHERE t.name = ? + ''', [tagName]); + return rows.map((r) => r['song_id'] as int).toList(); + } + + Future> tagCountsBerechnen() async { + final d = await db; + final rows = await d.rawQuery(''' + SELECT t.name, COUNT(st.song_id) as cnt + FROM tags t + LEFT JOIN song_tags st ON t.id = st.tag_id + GROUP BY t.name + '''); + return {for (final r in rows) r['name'] as String: r['cnt'] as int}; + } + // ─── Playlists ─────────────────────────────────── Future playlistErstellen(String name) async { @@ -224,6 +264,21 @@ class DbHelper { whereArgs: [playlistId, songId]); } + /// Aktualisiert die Position aller Songs in einer Playlist (nach Drag & Drop) + Future playlistReihenfolgeAktualisieren(int playlistId, List songIds) async { + final d = await db; + await d.transaction((txn) async { + for (int i = 0; i < songIds.length; i++) { + await txn.update( + 'playlist_songs', + {'position': i}, + where: 'playlist_id = ? AND song_id = ?', + whereArgs: [playlistId, songIds[i]], + ); + } + }); + } + Future metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album}) async { final d = await db; final update = {}; diff --git a/lib/screens/download_screen.dart b/lib/screens/download_screen.dart index dec2811..cf323e4 100644 --- a/lib/screens/download_screen.dart +++ b/lib/screens/download_screen.dart @@ -1,12 +1,10 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:just_audio/just_audio.dart'; import '../services/download_service.dart'; -import '../services/cloud_service.dart'; import '../utils/farb_theme.dart'; import '../services/melo_logger.dart'; -import '../widgets/melo_loader.dart'; class DownloadScreen extends StatefulWidget { final DownloadService downloader; @@ -22,95 +20,58 @@ class DownloadScreen extends StatefulWidget { State createState() => _DownloadScreenState(); } -class _DownloadScreenState extends State with WidgetsBindingObserver { +class _DownloadScreenState extends State { final _urlController = TextEditingController(); - final _cloud = CloudService(); - final _previewPlayer = AudioPlayer(); bool _ladt = false; - List> _globalSongs = []; - String? _previewSid; String? _fehler; String? _erfolg; - String _speicherOrt = 'App-intern (Music/)'; - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _previewPlayer.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { - _stopPreview(); - } - } - - Future _ladeGlobalListe() async { - final songs = await _cloud.globalList(); - if (mounted) setState(() => _globalSongs = songs.cast>()); - } - - Future _addFromRegistry(String sid) async { - final ok = await _cloud.download(sid, '/tmp/melo_reg_$sid.mp3'); - if (ok && mounted) { - setState(() => _erfolg = 'Song hinzugefügt!'); - widget.onSongsChanged(); - await _ladeGlobalListe(); - } - } - - Future _startPreview(String sid) async { - if (_previewSid == sid && _previewPlayer.playing) { - await _stopPreview(); - return; - } - _previewSid = sid; - try { - final url = 'http://159.195.51.99:8993/api/cloud/stream/$sid'; - await _previewPlayer.setUrl(url); - await _previewPlayer.seek(const Duration(seconds: 11)); - await _previewPlayer.play(); - Future.delayed(const Duration(seconds: 10), () { - if (_previewSid == sid) _stopPreview(); - }); - } catch (e) { - MeloLogger().fehler('preview', e); - } - } - - Future _stopPreview() async { - _previewSid = null; - await _previewPlayer.stop(); - } - bool _speichertInDownloads = false; + String _speicherOrt = 'Intern (Documents/music)'; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _ladeSpeicherPfad(); - _ladeGlobalListe(); + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); } Future _ladeSpeicherPfad() async { final prefs = await SharedPreferences.getInstance(); - final inDownloads = prefs.getBool('download_in_downloads') ?? false; - if (inDownloads) { - final dir = await getDownloadsDirectory(); - if (dir != null) { - final pfad = '${dir.path}/Melo'; - widget.downloader.setzeSpeicherPfad(pfad); - setState(() { - _speichertInDownloads = true; - _speicherOrt = '⬇ Downloads/Melo'; - }); - } + final pfad = prefs.getString('speicher_pfad'); + if (pfad != null && pfad.isNotEmpty) { + widget.downloader.setzeSpeicherPfad(pfad); + setState(() => _speicherOrt = pfad.split('/').last); + } else { + final pf = await _standardPfad(); + widget.downloader.setzeSpeicherPfad(pf); } } + Future _standardPfad() async { + if (Platform.isIOS) return '${(await getApplicationDocumentsDirectory()).path}/music'; + final p = await SharedPreferences.getInstance(); + if (p.getBool('manage_storage') == true) { + final d = await getDownloadsDirectory(); + if (d != null) return '${d.path}/Melo'; + } + final ext = await getExternalStorageDirectory(); + return ext != null ? '${ext.path}/Melo' : '${(await getApplicationDocumentsDirectory()).path}/music'; + } + Future _ordnerDialog() async { + if (Platform.isIOS) { + final appDir = await getApplicationDocumentsDirectory(); + final pfad = '${appDir.path}/music'; + widget.downloader.setzeSpeicherPfad(pfad); + setState(() => _speicherOrt = '📁 App-intern'); + return; + } + + // Android: Ordner wählen via Text-Eingabe oder vordefinierte Optionen final auswahl = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -119,94 +80,88 @@ class _DownloadScreenState extends State with WidgetsBindingObse content: Column( mainAxisSize: MainAxisSize.min, children: [ - _optionTile(ctx, '📁 App-intern (Music/)', 'intern', - icon: Icons.phone_android), + _optionTile(ctx, '📁 Intern (App-Ordner)', 'intern', icon: Icons.phone_android), const Divider(color: MeloTheme.dunkel2), - _optionTile(ctx, '⬇ Downloads/Melo', 'downloads', - icon: Icons.download), + _optionTile(ctx, '⬇ Downloads/Melo', 'downloads', icon: Icons.download), const Divider(color: MeloTheme.dunkel2), - _optionTile(ctx, '💾 SD-Karte / Extern', 'extern', - icon: Icons.sd_storage), + _optionTile(ctx, '📂 Eigener Pfad...', 'custom', icon: Icons.folder_open), ], ), ), ); + if (auswahl == null || !mounted) return; - if (auswahl == null) return; + String pfad; + if (auswahl == 'custom') { + final ctrl = TextEditingController(); + final p = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('Pfad eingeben', style: TextStyle(color: Colors.white, fontSize: 15)), + content: TextField( + controller: ctrl, autofocus: true, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: '/storage/emulated/0/Music/Melo', + hintStyle: const TextStyle(color: Colors.grey), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.folder, color: Colors.grey), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton(onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), child: const Text('OK', style: TextStyle(color: MeloTheme.rot))), + ], + ), + ); + if (p == null || p.isEmpty) return; + pfad = p; + } else if (auswahl == 'intern') { + pfad = await _standardPfad(); + } else { + final d = await getDownloadsDirectory(); + pfad = d != null ? '${d.path}/Melo' : await _standardPfad(); + } + + await Directory(pfad).create(recursive: true); + widget.downloader.setzeSpeicherPfad(pfad); final prefs = await SharedPreferences.getInstance(); - if (auswahl == 'downloads') { - final dir = await getDownloadsDirectory(); - if (dir != null) { - final pfad = '${dir.path}/Melo'; - await prefs.setBool('download_in_downloads', true); - widget.downloader.setzeSpeicherPfad(pfad); - setState(() { - _speichertInDownloads = true; - _speicherOrt = '⬇ Downloads/Melo'; - }); - } - } else if (auswahl == 'extern') { - final dirs = await getExternalStorageDirectories(); - if (dirs != null && dirs.isNotEmpty) { - final pfad = '${dirs.first.path}/Melo'; - await prefs.setBool('download_in_downloads', false); - widget.downloader.setzeSpeicherPfad(pfad); - setState(() { - _speichertInDownloads = false; - _speicherOrt = '💾 ${dirs.first.path.split('/').last}/Melo'; - }); - } else { - if (mounted) setState(() => _fehler = 'Kein externer Speicher gefunden'); - } - } else { - await prefs.setBool('download_in_downloads', false); - widget.downloader.setzeSpeicherPfad(''); - setState(() { - _speichertInDownloads = false; - _speicherOrt = '📁 App-intern (Music/)'; - }); - } + await prefs.setString('speicher_pfad', pfad); + + setState(() => _speicherOrt = pfad.split('/').last); } - Widget _optionTile(BuildContext ctx, String label, String wert, - {required IconData icon}) { + Widget _optionTile(BuildContext ctx, String label, String wert, {IconData? icon}) { return ListTile( - leading: Icon(icon, color: MeloTheme.rot, size: 20), - title: Text(label, - style: const TextStyle(color: Colors.white, fontSize: 13)), + leading: Icon(icon ?? Icons.folder, color: MeloTheme.rot, size: 20), + title: Text(label, style: const TextStyle(color: Colors.white, fontSize: 13)), onTap: () => Navigator.pop(ctx, wert), ); } - void _starteDownload() async { - final input = _urlController.text.trim(); - if (input.isEmpty) { - setState(() => _fehler = 'Bitte eine YouTube-URL einfügen'); - return; - } + Future _startDownload() async { + final url = _urlController.text.trim(); + if (url.isEmpty) return; setState(() { _ladt = true; _fehler = null; _erfolg = null; }); - MeloLogger().aktion('download_start', {'url': input.substring(0, 40)}); - final anzahl = await widget.downloader.downloadBatch(input); - if (mounted) { - setState(() { - _ladt = false; - if (anzahl > 0) { - _erfolg = '✅ $anzahl Song${anzahl > 1 ? 's' : ''} gespeichert'; - } else { - _fehler = widget.downloader.fehler ?? 'Download fehlgeschlagen'; - } - }); - widget.onSongsChanged(); + try { + final song = await widget.downloader.downloadVonUrl(url); + if (!mounted) return; + if (song != null) { + setState(() { _ladt = false; _erfolg = '✅ "${song.titel}" heruntergeladen!'; }); + widget.onSongsChanged(); + } else { + setState(() { _ladt = false; _fehler = widget.downloader.fehler ?? '❌ Download fehlgeschlagen'; }); + } + } catch (e) { + MeloLogger().fehler('download', e); + if (mounted) setState(() { _ladt = false; _fehler = 'Fehler: $e'; }); } } - void _abbrechen() { - widget.downloader.abbrechen(); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -214,271 +169,54 @@ class _DownloadScreenState extends State with WidgetsBindingObse appBar: AppBar( backgroundColor: MeloTheme.dunkel1, title: const Row(children: [ - Icon(Icons.download, color: MeloTheme.rot, size: 20), + Icon(Icons.add_circle, color: MeloTheme.rot, size: 20), SizedBox(width: 8), Text('Lied +', style: TextStyle(color: Colors.white, fontSize: 18)), ]), - actions: [ - if (_erfolg != null || _fehler != null) - IconButton( - icon: const Icon(Icons.refresh, color: Colors.grey, size: 20), - onPressed: () => setState(() { _fehler = null; _erfolg = null; _urlController.clear(); }), - ), - ], ), body: Padding( padding: const EdgeInsets.all(20), - child: Column( - children: [ - // ─── Globale Registry (Lied +) ─── - if (_globalSongs.isNotEmpty) ...[ - Row(children: [ - const Icon(Icons.public, color: MeloTheme.rot, size: 16), - const SizedBox(width: 6), - Text('Globale Songs (${_globalSongs.length})', - style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600)), - const Spacer(), - GestureDetector( - onTap: _ladeGlobalListe, - child: const Icon(Icons.refresh, color: Colors.grey, size: 16), - ), + child: Column(children: [ + GestureDetector( + onTap: _ladt ? null : _ordnerDialog, + child: Container( + width: double.infinity, padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: MeloTheme.dunkel1, borderRadius: BorderRadius.circular(12), border: Border.all(color: MeloTheme.dunkel2)), + child: Row(children: [ + const Icon(Icons.folder, color: MeloTheme.rot, size: 18), const SizedBox(width: 8), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Speicherort', style: TextStyle(color: Colors.grey, fontSize: 11)), + Text(_speicherOrt, style: const TextStyle(color: Colors.white, fontSize: 13)), + ])), + const Icon(Icons.chevron_right, color: Colors.grey, size: 18), ]), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: _globalSongs.length, - itemBuilder: (_, i) { - final s = _globalSongs[i]; - final sid = s['id']?.toString() ?? ''; - final title = s['title']?.toString() ?? '?'; - final isPreviewing = _previewSid == sid; - return Container( - width: 140, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: isPreviewing ? const Color(0xFF2A0000) : MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: isPreviewing ? MeloTheme.rot : MeloTheme.dunkel2), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(title, style: TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w500), - maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => _startPreview(sid), - child: Icon(isPreviewing ? Icons.stop : Icons.play_arrow, - color: isPreviewing ? Colors.white : MeloTheme.rot, size: 20), - ), - const SizedBox(width: 10), - GestureDetector( - onTap: () => _addFromRegistry(sid), - child: const Icon(Icons.add_circle_outline, color: Colors.grey, size: 18), - ), - ], - ), - ], - ), - ); - }, - ), - ), - const Divider(color: MeloTheme.dunkel2), - ], - // ─── Zielordner ─── - GestureDetector( - onTap: _ladt ? null : _ordnerDialog, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: MeloTheme.dunkel2), - ), - child: Row(children: [ - const Icon(Icons.folder, color: MeloTheme.rot, size: 18), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Speicherort', style: TextStyle(color: Colors.grey, fontSize: 11)), - Text(_speicherOrt, style: const TextStyle(color: Colors.white, fontSize: 13)), - ], - ), - ), - const Icon(Icons.chevron_right, color: Colors.grey, size: 18), - ]), - ), ), - const SizedBox(height: 12), - - // ─── Eingabefeld ─── - TextField( - controller: _urlController, - enabled: !_ladt, - maxLines: 3, - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - hintText: 'YouTube-URL hier einfügen...\n\nMehrere URLs: eine pro Zeile\nPlaylists werden erkannt 🎯', - hintStyle: const TextStyle(color: Colors.grey, fontSize: 13), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - filled: true, - fillColor: MeloTheme.dunkel1, - contentPadding: const EdgeInsets.all(16), - ), + ), + const SizedBox(height: 16), + TextField( + controller: _urlController, style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'YouTube / SoundCloud URL...', hintStyle: const TextStyle(color: Colors.grey), + filled: true, fillColor: MeloTheme.dunkel1, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: MeloTheme.dunkel2)), + prefixIcon: const Icon(Icons.link, color: Colors.grey), ), - const SizedBox(height: 12), - - // ─── Animierte Ladeanzeige (während Download) ─── - if (_ladt) ...[ - MeloLoader( - titel: widget.downloader.aktuellerTitel ?? 'Lade herunter...', - ), - const SizedBox(height: 16), - ], - - // ─── Download-Button ─── - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton.icon( - onPressed: _ladt ? null : _starteDownload, - icon: _ladt - ? const SizedBox(width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.download, size: 20), - label: Text(_ladt ? 'Lädt...' : '⬇ Download'), - style: ElevatedButton.styleFrom( - backgroundColor: MeloTheme.rot, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: MeloTheme.rot, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + onPressed: _ladt ? null : _startDownload, + icon: _ladt ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.download, color: Colors.white), + label: Text(_ladt ? 'Lädt...' : 'Download', style: const TextStyle(color: Colors.white, fontSize: 15)), ), - if (_ladt) ...[ - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - height: 40, - child: ElevatedButton.icon( - onPressed: _abbrechen, - icon: const Icon(Icons.cancel, size: 18), - label: const Text('Abbrechen'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade800, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - const SizedBox(height: 16), - - // ─── Fortschritt ─── - if (_ladt) - ListenableBuilder( - listenable: widget.downloader, - builder: (context, _) { - final fortschritt = widget.downloader.fortschritt; - - if (fortschritt <= 0) return const SizedBox.shrink(); - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(12), - ), - child: Column(children: [ - LinearProgressIndicator( - value: fortschritt, - color: MeloTheme.rot, - backgroundColor: MeloTheme.dunkel2), - const SizedBox(height: 4), - Text('${(fortschritt * 100).toStringAsFixed(0)}%', - style: const TextStyle(color: Colors.grey, fontSize: 11)), - ]), - ); - }, - ), - - // ─── Erfolg ─── - if (_erfolg != null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.3)), - ), - child: Row(children: [ - const Icon(Icons.check_circle, color: Colors.green, size: 24), - const SizedBox(width: 12), - Expanded(child: Text(_erfolg!, style: const TextStyle(color: Colors.green, fontSize: 13))), - ]), - ), - - // ─── Fehler ─── - if (_fehler != null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.red.withValues(alpha: 0.3)), - ), - child: Row(children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 24), - const SizedBox(width: 12), - Expanded(child: Text(_fehler!, style: const TextStyle(color: Colors.red, fontSize: 13))), - ]), - ), - - const Spacer(), - - // ─── Tipps ─── - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: MeloTheme.dunkel1, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('💡 Tipps', style: TextStyle(color: Colors.grey, fontSize: 12, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - _tipp('Einzel-URL: youtube.com/watch?v=...'), - _tipp('Playlist: youtube.com/playlist?list=...'), - _tipp('Mehrere: eine URL pro Zeile'), - _tipp('Cooldown: 5s zwischen Downloads ⏱'), - ], - ), - ), - const SizedBox(height: 20), - ], - ), + ), + const SizedBox(height: 12), + if (_fehler != null) Padding(padding: const EdgeInsets.only(top: 8), child: Text(_fehler!, style: const TextStyle(color: Colors.red, fontSize: 13))), + if (_erfolg != null) Padding(padding: const EdgeInsets.only(top: 8), child: Text(_erfolg!, style: const TextStyle(color: Colors.green, fontSize: 13))), + ]), ), ); } - - Widget _tipp(String text) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row(children: [ - const Text('• ', style: TextStyle(color: MeloTheme.rot, fontSize: 12)), - Expanded(child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 11))), - ]), - ); - } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 46bb73f..0977606 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -8,9 +8,12 @@ import '../models/playlist.dart'; import '../utils/farb_theme.dart'; import '../utils/user_effekte.dart'; import '../services/cloud_service.dart'; +import '../services/favoriten_service.dart'; import '../widgets/mini_player.dart'; import '../widgets/melo_header.dart'; import '../widgets/statistik_card.dart'; +import '../widgets/recent_widget.dart'; +import '../widgets/tag_stats_widget.dart'; import '../widgets/tag_leiste.dart'; import '../widgets/song_tile.dart'; import '../widgets/navidrome_browser.dart'; @@ -35,6 +38,7 @@ class _MeloHomeState extends State { int _aktiverTab = 0; String _nutzer = 'Baka'; // Aktueller Nutzer + final Future _favoritenZahl = FavoritenService().anzahlFavoriten(); @override void initState() { @@ -106,6 +110,8 @@ class _MeloHomeState extends State { Future _zeigeProfil() async { final p = await SharedPreferences.getInstance(); final modus = p.getString('melo_modus') ?? 'cloud'; + // Cloud-Count VOR dem Dialog auflösen (sonst stünde "Instance of Future" da) + final cloudCount = await _cloud.status(); if (!mounted) return; showDialog( context: context, @@ -120,7 +126,7 @@ class _MeloHomeState extends State { mainAxisSize: MainAxisSize.min, children: [ _profilZeile(Icons.music_note, 'Lieder auf Gerät', '${_vm.songs.length}'), - _profilZeile(Icons.cloud, 'Cloud-Server', '${_cloud.status().then((s) => s?['total'] ?? 0)}'), + _profilZeile(Icons.cloud, 'Cloud-Server', '${cloudCount?['total'] ?? 0}'), const Divider(color: MeloTheme.dunkel2), // Später von "nur lokal" auf Cloud wechseln – jederzeit möglich if (modus == 'lokal') ...[ @@ -543,6 +549,16 @@ class _MeloHomeState extends State { MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeProfil, onSettings: _zeigeEinstellungen), if (_vm.zeigeBotschaft) _botschaftBanner(), _tagBereich(), + FutureBuilder( + future: _favoritenZahl, + builder: (context, snap) => StatistikCard( + anzahlSongs: _vm.songs.length, + gesamtMB: gesamtMB, + gesamtMin: gesamtMin, + anzahlFavoriten: snap.data ?? 0, + ), + ), + RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong), Expanded(child: _songListe()), const MiniPlayer(), const SizedBox(height: 8), @@ -803,13 +819,42 @@ class _MeloHomeState extends State { Widget _tagBereich() { return AnimatedSize( duration: const Duration(milliseconds: 200), - child: _tagsOffen - ? TagLeiste( + child: Column( + children: [ + // Toggle-Kopf: Tag-Leiste ein-/ausklappen (war vorher unerreichbar!) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Icon(Icons.label_outline, + size: 14, color: MeloTheme.textSekundaer), + const SizedBox(width: 6), + Text('Tags & Filter', + style: const TextStyle( + color: MeloTheme.textSekundaer, fontSize: 12)), + const Spacer(), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon( + _tagsOffen ? Icons.expand_less : Icons.expand_more, + size: 18, + color: MeloTheme.textSekundaer), + onPressed: () => setState(() => _tagsOffen = !_tagsOffen), + ), + ], + ), + ), + if (_tagsOffen) ...[ + TagLeiste( tags: _vm.tags, aktiveTags: _vm.aktiveTags, onTagToggled: _vm.toggleTag, - ) - : const SizedBox.shrink(), + ), + const SizedBox(height: 8), + TagStatsWidget(tagCounts: _vm.tagCounts), + ], + ], + ), ); } diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart new file mode 100644 index 0000000..5c6cbb4 --- /dev/null +++ b/lib/services/audio_handler.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; +import '../models/song.dart'; +import 'player_service.dart'; + +/// Hintergrund-Audio-Handler für Sperrbildschirm & Benachrichtigung +class MeloAudioHandler extends BaseAudioHandler { + final PlayerService _player = PlayerService(); + + MeloAudioHandler() { + // Zustand vom Player an audio_service weiterleiten + _player.stateStream.listen(_updateState); + _player.positionStream.listen((pos) { + if (_playing) { + playbackState.add(playbackState.value.copyWith( + updatePosition: pos, + )); + } + }); + _player.onSongWechsel.listen((song) { + if (song != null) { + mediaItem.add(_toMediaItem(song)); + } + }); + } + + bool _playing = false; + + void _updateState(PlayerState state) { + _playing = state.playing; + playbackState.add(PlaybackState( + controls: _playing + ? [MediaControl.pause, MediaControl.skipToPrevious, MediaControl.skipToNext, MediaControl.stop] + : [MediaControl.play, MediaControl.skipToPrevious, MediaControl.skipToNext, MediaControl.stop], + systemActions: const {MediaAction.seek}, + androidCompactActionIndices: const [0, 1, 2], + processingState: _playing ? AudioProcessingState.ready : AudioProcessingState.idle, + playing: _playing, + speed: 1.0, + )); + } + + MediaItem _toMediaItem(Song song) => MediaItem( + id: song.id?.toString() ?? '0', + album: song.album.isEmpty ? 'Melo' : song.album, + title: song.titel, + artist: song.kuenstler, + duration: Duration(seconds: song.dauerSekunden), + artUri: song.coverPfad != null ? Uri.file(song.coverPfad!) : null, + ); + + @override + Future play() async { + if (_player.aktuellerSong != null) { + _playing = true; + await _player.playPause(); + } + } + + @override + Future pause() async { + _playing = false; + await _player.playPause(); + } + + @override + Future stop() async { + _playing = false; + await _player.playPause(); + playbackState.add(playbackState.value.copyWith( + controls: [MediaControl.play], + playing: false, + processingState: AudioProcessingState.idle, + )); + } + + @override + Future seek(Duration position) async { + _player.spiele(_player.aktuellerSong!, position: position.inSeconds); + } + + @override + Future skipToNext() => _player.naechstes(); + + @override + Future skipToPrevious() => _player.vorheriges(); + + // Exposed for the ViewModel + PlayerService get player => _player; +} diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index 067d9f6..6515eb5 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -9,6 +9,10 @@ import '../services/melo_logger.dart'; /// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort). /// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke). class CloudService { + static final CloudService _instanz = CloudService._(); + factory CloudService() => _instanz; + CloudService._(); + static String get _base => AppConfig.cloudUrl; static String get _authBase => AppConfig.authUrl; diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index f3bc9d5..5ea8d92 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -444,7 +444,7 @@ List<_UrlEintrag> _extrahiereUrls(String input) { final trimmed = zeile.trim(); if (trimmed.isEmpty) continue; - if (trimmed.contains('playlist') || trimmed.contains('list=')) { + if (trimmed.contains('list=')) { result.add(_UrlEintrag(trimmed, '📋 Playlist')); } else if (trimmed.contains('youtube.com') || trimmed.contains('youtu.be')) { result.add(_UrlEintrag(trimmed, '🎵 Song')); diff --git a/lib/services/favoriten_service.dart b/lib/services/favoriten_service.dart index 65565ce..cb07bb6 100644 --- a/lib/services/favoriten_service.dart +++ b/lib/services/favoriten_service.dart @@ -42,6 +42,13 @@ class FavoritenService { return _db.songsDerPlaylist(_favoritenPlaylistId!); } + Future anzahlFavoriten() async { + if (_favoritenPlaylistId == null) await init(); + if (_favoritenPlaylistId == null) return 0; + final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!); + return songs.length; + } + Future> favoritenIds() async { if (_favoritenPlaylistId == null) return {}; final d = await _db.db; diff --git a/lib/services/id3_reader.dart b/lib/services/id3_reader.dart new file mode 100644 index 0000000..42e8266 --- /dev/null +++ b/lib/services/id3_reader.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien +class Id3Reader { + /// Gibt Metadaten zurück: titel, kuenstler, album, coverBytes + static Map lesen(String filepath) { + final result = { + 'titel': '', + 'kuenstler': '', + 'album': '', + 'cover': null, + }; + + try { + final file = File(filepath); + if (!file.existsSync()) return result; + + final bytes = file.readAsBytesSync(); + // ID3v1 (letzte 128 Bytes) + if (bytes.length > 128) { + final tag = bytes.sublist(bytes.length - 128); + if (String.fromCharCodes(tag.sublist(0, 3)) == 'TAG') { + result['titel'] = _trimNull(tag.sublist(3, 33)).trim(); + result['kuenstler'] = _trimNull(tag.sublist(33, 63)).trim(); + result['album'] = _trimNull(tag.sublist(63, 93)).trim(); + } + } + + // ID3v2 Header (Anfang der Datei) für Cover + if (bytes.length > 10 && String.fromCharCodes(bytes.sublist(0, 3)) == 'ID3') { + final size = _synchSafeInt(bytes, 6); + var pos = 10; + // Frame-Header lesen + while (pos < size && pos + 10 < bytes.length) { + final frameId = String.fromCharCodes(bytes.sublist(pos, pos + 4)); + final frameSize = _frameSize(bytes, pos + 4); + pos += 10; + if (frameId == 'APIC' && pos + frameSize <= bytes.length) { + // APIC = Attached Picture + var p = pos; + // Encoding (1 Byte) + MIME-Type + final enc = bytes[p]; p += 1; + var mimeEnd = p; + while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) mimeEnd++; + final mime = String.fromCharCodes(bytes.sublist(p, mimeEnd)); + p = mimeEnd + 1; + // Picture Type (1 Byte) + p += 1; + // Description (null-terminated) + var descEnd = p; + while (descEnd < bytes.length) { + if (enc == 1 || enc == 2) { + if (descEnd + 1 < bytes.length && bytes[descEnd] == 0 && bytes[descEnd + 1] == 0) break; + descEnd += 2; + } else { + if (bytes[descEnd] == 0) break; + descEnd += 1; + } + } + p = descEnd + (enc == 1 || enc == 2 ? 2 : 1); + final remaining = pos + frameSize - p; + if (remaining > 0 && p + remaining <= bytes.length) { + result['cover'] = bytes.sublist(p, p + remaining); + } + break; + } + pos += frameSize; + } + } + } catch (_) {} + + // Fallback: Dateiname als Titel + if (result['titel'].isEmpty) { + result['titel'] = filepath.split('/').last.replaceAll('.mp3', '').replaceAll('.m4a', ''); + } + return result; + } + + static String _trimNull(List bytes) { + final end = bytes.indexWhere((b) => b == 0); + return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end)); + } + + static int _synchSafeInt(List bytes, int offset) { + return (bytes[offset] << 21) | (bytes[offset + 1] << 14) | (bytes[offset + 2] << 7) | bytes[offset + 3]; + } + + static int _frameSize(List bytes, int offset) { + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; + } +} diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index 5ed5507..6198894 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; +import 'id3_reader.dart'; +import 'melo_logger.dart'; class MusikScanner { static final MusikScanner _instanz = MusikScanner._(); @@ -17,8 +19,22 @@ class MusikScanner { int get anzahlNeueSongs => _neueSongs; Future frageSpeicherZugriff() async { - final status = await Permission.audio.request(); - return status.isGranted; + // App-interner Speicher (Cloud-Downloads) benötigt NIE Berechtigungen + if (Platform.isIOS) return true; + + if (Platform.isAndroid) { + // Android 13+: READ_MEDIA_AUDIO + var status = await Permission.audio.status; + if (!status.isGranted) status = await Permission.audio.request(); + if (status.isGranted) return true; + + // Fallback für Android 10 und älter + var storageStatus = await Permission.storage.status; + if (!storageStatus.isGranted) storageStatus = await Permission.storage.request(); + return storageStatus.isGranted; + } + + return true; } Future> scanneMusikOrdner() async { @@ -33,25 +49,45 @@ class MusikScanner { final file = File(pfad); if (!await file.exists()) continue; final stat = await file.stat(); + final tags = Id3Reader.lesen(pfad); + + // Cover-Bytes als Bilddatei speichern + String? coverPfad; + if (tags['cover'] != null && tags['cover'] is List) { + final bytes = tags['cover'] as List; + if (bytes.isNotEmpty) { + try { + final docDir = await getApplicationDocumentsDirectory(); + final coversDir = Directory('${docDir.path}/covers'); + if (!await coversDir.exists()) await coversDir.create(recursive: true); + final fileHash = pfad.hashCode.abs(); + final coverFile = File('${coversDir.path}/cover_$fileHash.jpg'); + await coverFile.writeAsBytes(bytes); + coverPfad = coverFile.path; + } catch (e) { + MeloLogger().fehler('cover_speichern', e); + } + } + } + gefunden.add(Song( - titel: _dateiNameOhneEndung(pfad), - kuenstler: 'Unbekannt', - album: '', + titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad), + kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt', + album: tags['album'] ?? '', dauerSekunden: await _ermittleDauer(player, pfad), dateiPfad: pfad, - coverPfad: null, + coverPfad: coverPfad, groesseBytes: stat.size, istHeruntergeladen: true, downloadQuelle: 'local', )); - } catch (_) { - // Datei nicht lesbar → überspringen + } catch (e) { + MeloLogger().fehler('scanner_datei', e); + continue; } } - try { - await player.dispose(); - } catch (_) {} + try { await player.dispose(); } catch (_) {} // In DB speichern final vorhandene = await _db.alleSongs(); @@ -68,56 +104,60 @@ class MusikScanner { Future> _sammleMusikPfade() async { final pfade = {}; - - // Typische Musik-Ordner auf Android - final ordner = [ - '/storage/emulated/0/Music', - '/storage/emulated/0/Download', - '/storage/emulated/0/Musik', - '/storage/emulated/0/Downloads', - '/sdcard/Music', - '/sdcard/Download', - '/sdcard/Musik', - ]; - - // Externe SD-Karte (falls vorhanden) + // 1. PRIMÄR: App-interner + Downloads-Ordner try { - final extern = await getExternalStorageDirectory(); - if (extern != null) { - ordner.add(extern.path); - } - } catch (_) {} + final appDir = await getApplicationDocumentsDirectory(); + final internDir = Directory('${appDir.path}/music'); + if (await internDir.exists()) await _durchsucheOrdner(internDir, pfade); - // Android Media Store (bessere Methode) - try { - final pfadeVonMediaStore = await _scanneViaMediaStore(); - pfade.addAll(pfadeVonMediaStore); - } catch (_) {} - - // Fallback: Dateisystem durchsuchen - for (final ord in ordner) { - try { - final dir = Directory(ord); - if (await dir.exists()) { - await _durchsucheOrdner(dir, pfade); + // Downloads/Melo (für Dateimanager sichtbar) + if (Platform.isAndroid) { + final dlDir = await getDownloadsDirectory(); + if (dlDir != null) { + final meloDir = Directory('${dlDir.path}/Melo'); + if (await meloDir.exists()) await _durchsucheOrdner(meloDir, pfade); } + } + } catch (e) { + MeloLogger().fehler('scanner_intern_pfad', e); + } + + // 2. Externe System-Ordner (nur Android) + if (Platform.isAndroid) { + final ordner = [ + '/storage/emulated/0/Music', + '/storage/emulated/0/Download', + '/storage/emulated/0/Musik', + '/storage/emulated/0/Downloads', + '/sdcard/Music', + '/sdcard/Download', + ]; + + try { + final extern = await getExternalStorageDirectory(); + if (extern != null) ordner.add(extern.path); } catch (_) {} + + for (final ord in ordner) { + try { + final dir = Directory(ord); + if (await dir.exists()) { + await _durchsucheOrdner(dir, pfade); + } + } catch (e) { + MeloLogger().fehler('scanner_ordner_zugriff_${ord.split('/').last}', e); + } + } } return pfade.toList(); } - Future> _scanneViaMediaStore() async { - // Nutzt Android's MediaStore Query - // Wird über Method Channel in native Android implementiert - // Für v1: Fallback auf Dateisystem-Suche - return []; - } - Future _durchsucheOrdner(Directory dir, Set pfade, {int tiefe = 0}) async { if (tiefe > 4) return; try { - await for (final entity in dir.list(followLinks: false)) { + final stream = dir.list(followLinks: false); + await for (final entity in stream.handleError((_) {})) { if (entity is File) { final ext = entity.path.toLowerCase(); if (ext.endsWith('.mp3') || ext.endsWith('.m4a') || @@ -129,15 +169,18 @@ class MusikScanner { await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1); } } - } catch (_) {} + } catch (e) { + MeloLogger().fehler('scanner_durchsuchen_fehler', '$dir: $e'); + } } Future _ermittleDauer(AudioPlayer player, String pfad) async { try { - await player.setFilePath(pfad); + await player.setFilePath(pfad).timeout(const Duration(milliseconds: 1500)); final dauer = player.duration; return dauer?.inSeconds ?? 0; - } catch (_) { + } catch (e) { + try { await player.stop(); } catch (_) {} return 0; } } diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 8071a97..8a76709 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import '../models/song.dart'; +import 'melo_logger.dart'; class PlayerService { static final PlayerService _instanz = PlayerService._(); @@ -51,7 +52,7 @@ class PlayerService { // Lokale Datei await _p.setFilePath(song.dateiPfad); } else { - debugPrint('Keine gültige Quelle für: ${song.titel}'); + MeloLogger().fehler('player_keine_quelle', 'Keine gültige Quelle für: ${song.titel}'); return; } if (position > 0) await _p.seek(Duration(seconds: position)); diff --git a/lib/services/playlist_service.dart b/lib/services/playlist_service.dart index 748898c..5dd6189 100644 --- a/lib/services/playlist_service.dart +++ b/lib/services/playlist_service.dart @@ -33,6 +33,11 @@ class PlaylistService { await _db.songAusPlaylistEntfernen(playlistId, songId); } + Future reihenfolgeSpeichern(int playlistId, List songs) async { + final songIds = songs.map((s) => s.id).whereType().toList(); + await _db.playlistReihenfolgeAktualisieren(playlistId, songIds); + } + Future istInPlaylist(int playlistId, int songId) async { final songs = await _db.songsDerPlaylist(playlistId); return songs.any((s) => s.id == songId); diff --git a/lib/widgets/cloud_einstellungen.dart b/lib/widgets/cloud_einstellungen.dart index 4ab5959..e36aee5 100644 --- a/lib/widgets/cloud_einstellungen.dart +++ b/lib/widgets/cloud_einstellungen.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/cloud_service.dart'; @@ -16,7 +15,6 @@ class CloudEinstellungen extends StatefulWidget { class _CloudEinstellungenState extends State { bool _autoSync = true; int _syncIntervall = 6; // Stunden - Timer? _syncTimer; @override void initState() { @@ -24,12 +22,6 @@ class _CloudEinstellungenState extends State { _ladeSettings(); } - @override - void dispose() { - _syncTimer?.cancel(); - super.dispose(); - } - Future _ladeSettings() async { final p = await SharedPreferences.getInstance(); if (mounted) { @@ -39,19 +31,9 @@ class _CloudEinstellungenState extends State { }); } } - - void _starteAutoSync() { - _syncTimer?.cancel(); - if (!_autoSync || _syncIntervall == 0) return; - _syncTimer = Timer.periodic( - Duration(hours: _syncIntervall), - (_) => _triggerSync(), - ); - } - - void _triggerSync() { - // Wird vom CloudService in cloud_screen.dart erledigt - } + // Hinweis: Der echte Auto-Sync-Timer läuft im MeloHomeViewModel + // (_starteCloudSyncScheduler). Dieser Dialog speichert nur die Einstellungen; + // die Änderungen greifen beim nächsten App-Start bzw. über "Jetzt synchronisieren". @override Widget build(BuildContext context) { @@ -76,8 +58,7 @@ class _CloudEinstellungenState extends State { onChanged: (v) async { setState(() => _autoSync = v); final p = await SharedPreferences.getInstance(); - p.setBool('cloud_auto', v); - _starteAutoSync(); + await p.setBool('cloud_auto', v); }, ), const Divider(color: MeloTheme.dunkel2), @@ -95,8 +76,7 @@ class _CloudEinstellungenState extends State { if (v == null) return; setState(() => _syncIntervall = v); final p = await SharedPreferences.getInstance(); - p.setInt('cloud_interval', v); - _starteAutoSync(); + await p.setInt('cloud_interval', v); }, ); }), diff --git a/lib/widgets/melo_header.dart b/lib/widgets/melo_header.dart index 194c95f..8af287b 100644 --- a/lib/widgets/melo_header.dart +++ b/lib/widgets/melo_header.dart @@ -5,8 +5,9 @@ class MeloHeader extends StatelessWidget { final VoidCallback onDownload; final VoidCallback onSearch; final VoidCallback? onServer; + final VoidCallback? onSettings; - const MeloHeader({super.key, required this.onDownload, required this.onSearch, this.onServer}); + const MeloHeader({super.key, required this.onDownload, required this.onSearch, this.onServer, this.onSettings}); @override Widget build(BuildContext context) { @@ -39,6 +40,10 @@ class MeloHeader extends StatelessWidget { _btn(Icons.download, onDownload), const SizedBox(width: 8), _btn(Icons.search, onSearch), + if (onSettings != null) ...[ + const SizedBox(width: 8), + _btn(Icons.settings, onSettings!), + ], ]), ], ), diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 4188f82..224ffea 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import '../services/player_service.dart'; +import '../models/song.dart'; import '../utils/farb_theme.dart'; class MiniPlayer extends StatefulWidget { @@ -18,6 +20,7 @@ class _MiniPlayerState extends State { bool _spielt = false; StreamSubscription? _posSub; StreamSubscription? _stateSub; + StreamSubscription? _songSub; @override void initState() { @@ -36,12 +39,17 @@ class _MiniPlayerState extends State { }); } }); + // Sofortige UI-Aktualisierung bei jedem Songwechsel + _songSub = _player.onSongWechsel.listen((_) { + if (mounted) setState(() {}); + }); } @override void dispose() { _posSub?.cancel(); _stateSub?.cancel(); + _songSub?.cancel(); super.dispose(); } @@ -52,6 +60,7 @@ class _MiniPlayerState extends State { final progress = _dauer.inSeconds > 0 ? _position.inSeconds / _dauer.inSeconds : 0.0; + final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync(); return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -67,13 +76,15 @@ class _MiniPlayerState extends State { padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), child: Row( children: [ - // Cover + // Cover mit Fallback auf Notensymbol ClipRRect( borderRadius: BorderRadius.circular(10), child: Container( width: 36, height: 36, color: MeloTheme.rot, - child: const Center(child: Text('♪', style: TextStyle(fontSize: 16))), + child: hatCover + ? Image.file(File(song.coverPfad!), fit: BoxFit.cover) + : const Center(child: Text('♪', style: TextStyle(fontSize: 16))), ), ), const SizedBox(width: 10), diff --git a/lib/widgets/navidrome_browser.dart b/lib/widgets/navidrome_browser.dart index d1a26ed..2f24b11 100644 --- a/lib/widgets/navidrome_browser.dart +++ b/lib/widgets/navidrome_browser.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../services/navidrome_service.dart'; import '../utils/farb_theme.dart'; import '../viewmodels/melo_home_viewmodel.dart'; +import '../config/app_config.dart'; /// Navidrome-Browser als Bottom-Sheet. /// Manuell in home_screen.dart einbaubar. @@ -216,7 +217,7 @@ class _NavidromeBrowserState extends State { } void _zeigeLoginDialog(BuildContext context) { - final urlCtrl = TextEditingController(); + final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl); final userCtrl = TextEditingController(); final passCtrl = TextEditingController(); bool verbindet = false; @@ -258,6 +259,8 @@ class _NavidromeBrowserState extends State { final ok = await widget.vm.ladeNavidromeAlben(); verbindet = false; if (ok && ctx.mounted) { + // Zugangsdaten speichern + await widget.vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text); Navigator.pop(ctx); if (context.mounted) setState(() {}); } else if (ctx.mounted) { diff --git a/lib/widgets/playlist_sheet.dart b/lib/widgets/playlist_sheet.dart index 1d97693..d833118 100644 --- a/lib/widgets/playlist_sheet.dart +++ b/lib/widgets/playlist_sheet.dart @@ -118,7 +118,7 @@ class _PlaylistSheetState extends State { isScrollControlled: true, builder: (_) => SizedBox( height: MediaQuery.of(context).size.height * 0.6, - child: _PlaylistDetail(p: p, songs: songs, vm: widget.vm, onChanged: _laden), + child: _PlaylistDetail(p: p, initialSongs: songs, vm: widget.vm, onChanged: _laden), ), ); } @@ -172,19 +172,32 @@ class _PlaylistSheetState extends State { } /// Detailansicht einer Playlist mit Songs -class _PlaylistDetail extends StatelessWidget { +class _PlaylistDetail extends StatefulWidget { final Playlist p; - final List songs; + final List initialSongs; final MeloHomeViewModel vm; final VoidCallback onChanged; const _PlaylistDetail({ required this.p, - required this.songs, + required this.initialSongs, required this.vm, required this.onChanged, }); + @override + State<_PlaylistDetail> createState() => _PlaylistDetailState(); +} + +class _PlaylistDetailState extends State<_PlaylistDetail> { + late List _songs; + + @override + void initState() { + super.initState(); + _songs = List.from(widget.initialSongs); + } + @override Widget build(BuildContext context) { return Padding( @@ -209,18 +222,29 @@ class _PlaylistDetail extends StatelessWidget { 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)), + Text(widget.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)), + Text('${_songs.length} Songs', style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), ], ), const SizedBox(height: 12), Expanded( - child: songs.isEmpty + 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]), + : ReorderableListView.builder( + itemCount: _songs.length, + onReorder: (oldIndex, newIndex) async { + if (newIndex > oldIndex) newIndex--; + setState(() { + final moved = _songs.removeAt(oldIndex); + _songs.insert(newIndex, moved); + }); + if (widget.p.id != null) { + await widget.vm.playlists.reihenfolgeSpeichern(widget.p.id!, _songs); + } + widget.onChanged(); + }, + itemBuilder: (_, i) => _songTile(context, _songs[i]), ), ), ], @@ -230,6 +254,7 @@ class _PlaylistDetail extends StatelessWidget { Widget _songTile(BuildContext context, Song song) { return Container( + key: ValueKey(song.id ?? song.dateiPfad), margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -249,7 +274,10 @@ class _PlaylistDetail extends StatelessWidget { const SizedBox(width: 8), Expanded( child: GestureDetector( - onTap: () { Navigator.pop(context); vm.spieleSong(song); }, + onTap: () { + Navigator.pop(context); + widget.vm.spieleSong(song, warteschlange: _songs); + }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -261,9 +289,11 @@ class _PlaylistDetail extends StatelessWidget { ), GestureDetector( onTap: () async { - await vm.playlists.songEntfernen(p.id!, song.id!); - onChanged(); - if (context.mounted) Navigator.pop(context); + await widget.vm.playlists.songEntfernen(widget.p.id!, song.id!); + setState(() { + _songs.removeWhere((s) => s.id == song.id); + }); + widget.onChanged(); }, child: const Icon(Icons.remove_circle_outline, size: 18, color: MeloTheme.textSekundaer), ), diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart index 88d0848..91af942 100644 --- a/lib/widgets/song_tile.dart +++ b/lib/widgets/song_tile.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import '../models/song.dart'; import '../utils/farb_theme.dart'; import 'metadaten_dialog.dart'; +import 'tag_auswahl_dialog.dart'; class SongTile extends StatelessWidget { final Song song; @@ -10,6 +12,7 @@ class SongTile extends StatelessWidget { final ValueChanged onPlay; final VoidCallback onMetadataChanged; final ValueChanged? onAddToPlaylist; + final ValueChanged? onDelete; const SongTile({ super.key, @@ -19,20 +22,26 @@ class SongTile extends StatelessWidget { required this.onPlay, required this.onMetadataChanged, this.onAddToPlaylist, + this.onDelete, }); @override Widget build(BuildContext context) { final hatDatei = song.dateiPfad.isNotEmpty; + final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync(); return ListTile( contentPadding: const EdgeInsets.symmetric(vertical: 2), - leading: Container( - width: 44, height: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]), + leading: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 44, height: 44, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]), + ), + child: hatCover + ? Image.file(File(song.coverPfad!), fit: BoxFit.cover) + : const Center(child: Text('♪', style: TextStyle(fontSize: 18, color: Colors.white54))), ), - child: const Center(child: Text('♪', style: TextStyle(fontSize: 18, color: Colors.white54))), ), title: Text(song.titel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), subtitle: Text(song.kuenstler, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), @@ -53,6 +62,26 @@ class SongTile extends StatelessWidget { child: Icon(Icons.edit, size: 14, color: MeloTheme.textSekundaer), ), ), + InkWell( + borderRadius: BorderRadius.circular(50), + onTap: () { + // Guard gegen null-ID (defensive) + final id = song.id; + if (id == null) return; + showDialog( + context: context, + builder: (_) => TagAuswahlDialog( + songId: id, + songTitel: song.titel, + onChanged: onMetadataChanged, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(6), + child: const Icon(Icons.label_outline, size: 14, color: MeloTheme.textSekundaer), + ), + ), if (onAddToPlaylist != null) InkWell( borderRadius: BorderRadius.circular(50), @@ -77,6 +106,39 @@ class SongTile extends StatelessWidget { ], ), onTap: hatDatei ? () => onPlay(song) : null, + onLongPress: onDelete != null ? () { + showModalBottomSheet( + context: context, + backgroundColor: MeloTheme.dunkel1, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.edit, color: Colors.white), + title: const Text('Bearbeiten', style: TextStyle(color: Colors.white)), + onTap: () async { + Navigator.pop(ctx); + final geaendert = await showDialog( + context: context, + builder: (_) => MetadatenDialog(song: song), + ); + if (geaendert == true) onMetadataChanged(); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('Löschen', style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(ctx); + onDelete!(song); + }, + ), + ], + ), + ), + ); + } : null, ); } } diff --git a/lib/widgets/tag_auswahl_dialog.dart b/lib/widgets/tag_auswahl_dialog.dart new file mode 100644 index 0000000..d1d59c7 --- /dev/null +++ b/lib/widgets/tag_auswahl_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../database/db_helper.dart'; +import '../models/tag.dart'; +import '../utils/farb_theme.dart'; +import '../services/melo_logger.dart'; + +/// Dialog: Tags für einen Song zuweisen/entfernen +class TagAuswahlDialog extends StatefulWidget { + final int songId; + final String songTitel; + final VoidCallback onChanged; + + const TagAuswahlDialog({ + super.key, + required this.songId, + required this.songTitel, + required this.onChanged, + }); + + @override + State createState() => _TagAuswahlDialogState(); +} + +class _TagAuswahlDialogState extends State { + final _db = DbHelper(); + List _alleTags = []; + Set _zugewiesenIds = {}; + + @override + void initState() { + super.initState(); + _laden(); + } + + Future _laden() async { + final alle = await _db.alleTags(); + final zugewiesen = await _db.tagsFuerSong(widget.songId); + if (mounted) { + setState(() { + _alleTags = alle; + _zugewiesenIds = zugewiesen.map((t) => t.id!).toSet(); + }); + } + } + + Future _toggle(int tagId) async { + if (_zugewiesenIds.contains(tagId)) { + await _db.songTagEntfernen(widget.songId, tagId); + _zugewiesenIds.remove(tagId); + } else { + await _db.songTagHinzufuegen(widget.songId, tagId); + _zugewiesenIds.add(tagId); + } + widget.onChanged(); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: Text( + '🏷 Tags für "${widget.songTitel}"', + style: const TextStyle(color: Colors.white, fontSize: 15), + maxLines: 2, overflow: TextOverflow.ellipsis, + ), + content: SizedBox( + width: double.maxFinite, + child: _alleTags.isEmpty + ? const Center(child: CircularProgressIndicator(color: MeloTheme.rot)) + : ListView.builder( + shrinkWrap: true, + itemCount: _alleTags.length, + itemBuilder: (_, i) { + final tag = _alleTags[i]; + final aktiv = _zugewiesenIds.contains(tag.id); + return CheckboxListTile( + value: aktiv, + activeColor: MeloTheme.rot, + title: Text( + '${tag.icon ?? ''} ${tag.name}', + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + onChanged: (_) => _toggle(tag.id!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ); + }, + ), + ), + ); + } +}