This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/widgets/song_tile.dart
T
Dustin bf0f544e4b v2.52.1 — F2: YT-Links nur als Server-Fallback
## YT-Quell-URL (Server ist die Quelle)
- cloud_service.ytUrlSetzen(): POST /api/v1/cloud/yt-url (per-User UPDATE user_songs.yt_url)
- musik_scanner._sucheYtUrls(): durchsucht NUR Songs mit cloud_id, die auf dem Server noch keine yt_url haben; Treffer werden auf den Server hochgeladen statt lokal gespeichert
- db_helper: ytUrlAktualisieren/songsOhneYtUrl entfernt, songsMitCloudId() ergänzt
- download_service: yt_url wird beim Download nicht mehr lokal persistiert

## Fallback-Kette reDownload (korrupte/fehlende Datei)
- 1) Server-Download via cloud_id + Magic-Byte-Check (primäre Quelle)
- 2) Server-ytUrl (aus listSongs) → yt-proxy als letzte Chance
- 3) Legacy: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52)
- song_tile: „Neu laden“ erscheint bei istKorrupt && (cloudId != null || ytUrl != null)
- 3 neue Tests (ytUrlSetzen: ok/error/401) → 111 Tests grün, analyze 0 Issues
2026-08-05 10:40:37 +02:00

287 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '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;
final bool istFavorit;
final ValueChanged<Song> onFavoriteToggle;
final ValueChanged<Song> onPlay;
final VoidCallback onMetadataChanged;
final ValueChanged<Song>? onAddToPlaylist;
final ValueChanged<Song>? onErneutHerunterladen;
final ValueChanged<Song>? onSpieleAlsNaechstes;
final ValueChanged<Song>? onAmEndeHinzufuegen;
const SongTile({
super.key,
required this.song,
required this.istFavorit,
required this.onFavoriteToggle,
required this.onPlay,
required this.onMetadataChanged,
this.onAddToPlaylist,
this.onErneutHerunterladen,
this.onSpieleAlsNaechstes,
this.onAmEndeHinzufuegen,
});
@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: 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))),
),
),
title: Row(
children: [
if (song.istKorrupt)
const Padding(
padding: EdgeInsets.only(right: 6),
child: Text('⚠️', style: TextStyle(fontSize: 16)),
),
Expanded(
child: Text(
song.titel,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: song.istKorrupt ? MeloTheme.textSekundaer : Colors.white,
decoration: song.istKorrupt ? TextDecoration.lineThrough : null,
),
),
),
],
),
subtitle: Text(song.kuenstler, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// ── Korrupt: 🔄 Neu laden (Server-Quelle via cloud_id oder Legacy-ytUrl) ──
if (song.istKorrupt &&
(song.cloudId != null || song.ytUrl != null) &&
onErneutHerunterladen != null)
InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () => onErneutHerunterladen!(song),
child: const Padding(
padding: EdgeInsets.all(6),
child: Text('🔄', style: TextStyle(fontSize: 16)),
),
),
InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () async {
final geaendert = await showDialog<bool>(
context: context,
builder: (_) => MetadatenDialog(song: song),
);
if (geaendert == true) onMetadataChanged();
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(Icons.edit, size: 14, color: MeloTheme.textSekundaer),
),
),
InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () => showDialog(
context: context,
builder: (_) => TagAuswahlDialog(
songId: song.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),
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),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
istFavorit ? Icons.favorite : Icons.favorite_border,
size: 16, color: MeloTheme.rot,
),
),
),
Text(song.dauerFormatiert, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
],
),
onTap: hatDatei ? () => onPlay(song) : null,
onLongPress: hatDatei ? () => _zeigeKontextMenu(context) : null,
);
}
/// Kontextmenü per Long-Press: Schnellaktionen auf den Song.
/// Zeigt nur Aktionen, deren Funktionen verfügbar sind:
/// ▶️ Abspielen · Warteschlange · ⭐ Favorit · ⬇️ Download · 🏷 Tags · ✏️ Umbenennen
void _zeigeKontextMenu(BuildContext context) {
final hatTags = song.id != null;
showModalBottomSheet(
context: context,
backgroundColor: MeloTheme.dunkel1,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Song-Info-Kopfzeile
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
),
child: const Center(child: Text('♪', style: TextStyle(fontSize: 14, color: Colors.white54))),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.titel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
),
Text(
song.kuenstler,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12),
),
],
),
),
],
),
),
const Divider(color: MeloTheme.dunkel2, height: 12),
// ▶️ Abspielen
ListTile(
leading: const Icon(Icons.play_arrow, color: MeloTheme.rot, size: 20),
title: const Text('Abspielen', style: TextStyle(color: Colors.white, fontSize: 14)),
onTap: () {
Navigator.pop(ctx);
onPlay(song);
},
),
// Warteschlange (Play Next)
if (onSpieleAlsNaechstes != null)
ListTile(
leading: const Icon(Icons.playlist_play, color: MeloTheme.rot, size: 20),
title: const Text('Als Nächstes abspielen', style: TextStyle(color: Colors.white, fontSize: 14)),
onTap: () {
Navigator.pop(ctx);
onSpieleAlsNaechstes!.call(song);
},
),
// Am Ende hinzufügen
if (onAmEndeHinzufuegen != null)
ListTile(
leading: const Icon(Icons.playlist_add, color: MeloTheme.rot, size: 20),
title: const Text('Am Ende hinzufügen', style: TextStyle(color: Colors.white, fontSize: 14)),
onTap: () {
Navigator.pop(ctx);
onAmEndeHinzufuegen!(song);
},
),
// ⭐ Favorit
ListTile(
leading: Icon(istFavorit ? Icons.favorite : Icons.favorite_border, color: MeloTheme.rot, size: 20),
title: Text(
istFavorit ? 'Favorit entfernen' : 'Zu Favoriten',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
onTap: () {
Navigator.pop(ctx);
onFavoriteToggle(song);
},
),
// ⬇️ Neu laden wenn Server-Quelle (cloud_id) oder Legacy-ytUrl existiert
if ((song.cloudId != null || song.ytUrl != null) && onErneutHerunterladen != null)
ListTile(
leading: const Icon(Icons.download, color: MeloTheme.rot, size: 20),
title: Text(
song.istKorrupt ? 'Erneut herunterladen' : 'Neu laden',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
onTap: () {
Navigator.pop(ctx);
onErneutHerunterladen!(song);
},
),
// 🏷 Tags nur wenn der Song in der DB ist
if (hatTags)
ListTile(
leading: const Icon(Icons.label_outline, color: MeloTheme.rot, size: 20),
title: const Text('Tags bearbeiten', style: TextStyle(color: Colors.white, fontSize: 14)),
onTap: () {
Navigator.pop(ctx);
showDialog(
context: context,
builder: (_) => TagAuswahlDialog(
songId: song.id!,
songTitel: song.titel,
onChanged: onMetadataChanged,
),
);
},
),
// ✏️ Umbenennen (Metadaten)
if (hatTags)
ListTile(
leading: const Icon(Icons.edit, color: MeloTheme.rot, size: 20),
title: const Text('Umbenennen', style: TextStyle(color: Colors.white, fontSize: 14)),
onTap: () async {
Navigator.pop(ctx);
final geaendert = await showDialog<bool>(
context: context,
builder: (_) => MetadatenDialog(song: song),
);
if (geaendert == true) onMetadataChanged();
},
),
const SizedBox(height: 8),
],
),
),
);
}
}