Initial commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../models/song.dart';
|
||||
import '../models/tag.dart';
|
||||
|
||||
class DbHelper {
|
||||
static final DbHelper _instanz = DbHelper._();
|
||||
factory DbHelper() => _instanz;
|
||||
DbHelper._();
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get db async {
|
||||
if (_db != null) return _db!;
|
||||
_db = await _init();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _init() async {
|
||||
final pfad = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
p.join(pfad, 'melo.db'),
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
titel TEXT NOT NULL,
|
||||
kuenstler TEXT NOT NULL,
|
||||
album TEXT DEFAULT '',
|
||||
dauer_sekunden INTEGER NOT NULL,
|
||||
datei_pfad TEXT NOT NULL UNIQUE,
|
||||
cover_pfad TEXT,
|
||||
groesse_bytes INTEGER DEFAULT 0,
|
||||
ist_heruntergeladen INTEGER DEFAULT 0,
|
||||
hinzugefuegt_am TEXT NOT NULL,
|
||||
download_quelle TEXT DEFAULT 'local',
|
||||
zuletzt_position INTEGER
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT,
|
||||
farbe_hex TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE song_tags (
|
||||
song_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (song_id, tag_id),
|
||||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
erstellt_am TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE playlist_songs (
|
||||
playlist_id INTEGER NOT NULL,
|
||||
song_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (playlist_id, song_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE wiedergabe_verlauf (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
song_id INTEGER NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
zuletzt_abgespielt TEXT NOT NULL,
|
||||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Songs ──────────────────────────────────────
|
||||
|
||||
Future<int> songEinfuegen(Song song) async {
|
||||
final d = await db;
|
||||
return d.insert('songs', song.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
|
||||
Future<void> songsEinfuegen(List<Song> songs) async {
|
||||
final d = await db;
|
||||
final batch = d.batch();
|
||||
for (final song in songs) {
|
||||
batch.insert('songs', song.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<Song>> alleSongs() async {
|
||||
final d = await db;
|
||||
final rows = await d.query('songs', orderBy: 'hinzugefuegt_am DESC');
|
||||
return rows.map((r) => Song.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
Future<Song?> songNachId(int id) async {
|
||||
final d = await db;
|
||||
final rows = await d.query('songs', where: 'id = ?', whereArgs: [id]);
|
||||
if (rows.isEmpty) return null;
|
||||
return Song.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<Song?> songNachPfad(String pfad) async {
|
||||
final d = await db;
|
||||
final rows = await d.query('songs', where: 'datei_pfad = ?', whereArgs: [pfad]);
|
||||
if (rows.isEmpty) return null;
|
||||
return Song.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<void> positionAktualisieren(int songId, int position) async {
|
||||
final d = await db;
|
||||
await d.update('songs', {'zuletzt_position': position},
|
||||
where: 'id = ?', whereArgs: [songId]);
|
||||
final rows = await d.update('wiedergabe_verlauf',
|
||||
{'position': position, 'zuletzt_abgespielt': DateTime.now().toIso8601String()},
|
||||
where: 'song_id = ?', whereArgs: [songId]);
|
||||
if (rows == 0) {
|
||||
await d.insert('wiedergabe_verlauf', {
|
||||
'song_id': songId,
|
||||
'position': position,
|
||||
'zuletzt_abgespielt': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags ────────────────────────────────────────
|
||||
|
||||
Future<int> tagErstellen(String name, {String? icon, String? farbe}) async {
|
||||
final d = await db;
|
||||
return d.insert('tags', {
|
||||
'name': name, 'icon': icon, 'farbe_hex': farbe,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
|
||||
Future<List<Tag>> alleTags() async {
|
||||
final d = await db;
|
||||
final rows = await d.query('tags', orderBy: 'name');
|
||||
return rows.map((r) => Tag.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
Future<void> songTagHinzufuegen(int songId, int tagId) async {
|
||||
final d = await db;
|
||||
await d.insert('song_tags', {'song_id': songId, 'tag_id': tagId},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
}
|
||||
|
||||
Future<List<Tag>> tagsFuerSong(int songId) async {
|
||||
final d = await db;
|
||||
final rows = await d.rawQuery('''
|
||||
SELECT t.* FROM tags t
|
||||
JOIN song_tags st ON t.id = st.tag_id
|
||||
WHERE st.song_id = ?
|
||||
''', [songId]);
|
||||
return rows.map((r) => Tag.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
// ─── Playlists ───────────────────────────────────
|
||||
|
||||
Future<int> playlistErstellen(String name) async {
|
||||
final d = await db;
|
||||
return d.insert('playlists', {
|
||||
'name': name,
|
||||
'erstellt_am': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> songZurPlaylist(int playlistId, int songId, int position) async {
|
||||
final d = await db;
|
||||
await d.insert('playlist_songs', {
|
||||
'playlist_id': playlistId,
|
||||
'song_id': songId,
|
||||
'position': position,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> allePlaylists() async {
|
||||
final d = await db;
|
||||
return d.query('playlists', orderBy: 'erstellt_am DESC');
|
||||
}
|
||||
|
||||
Future<void> songAusPlaylistEntfernen(int playlistId, int songId) async {
|
||||
final d = await db;
|
||||
await d.delete('playlist_songs',
|
||||
where: 'playlist_id = ? AND song_id = ?',
|
||||
whereArgs: [playlistId, songId]);
|
||||
}
|
||||
|
||||
Future<void> metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album}) async {
|
||||
final d = await db;
|
||||
final update = <String, dynamic>{};
|
||||
if (titel != null) update['titel'] = titel;
|
||||
if (kuenstler != null) update['kuenstler'] = kuenstler;
|
||||
if (album != null) update['album'] = album;
|
||||
if (update.isNotEmpty) {
|
||||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Song>> songsDerPlaylist(int playlistId) async {
|
||||
final d = await db;
|
||||
final rows = await d.rawQuery('''
|
||||
SELECT s.* FROM songs s
|
||||
JOIN playlist_songs ps ON s.id = ps.song_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position
|
||||
''', [playlistId]);
|
||||
return rows.map((r) => Song.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
Future<void> loeschen() async {
|
||||
final d = await db;
|
||||
await d.transaction((txn) async {
|
||||
await txn.delete('wiedergabe_verlauf');
|
||||
await txn.delete('song_tags');
|
||||
await txn.delete('playlist_songs');
|
||||
await txn.delete('playlists');
|
||||
await txn.delete('tags');
|
||||
await txn.delete('songs');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'database/db_helper.dart';
|
||||
import 'services/favoriten_service.dart';
|
||||
import 'utils/farb_theme.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
try {
|
||||
await DbHelper().db;
|
||||
await FavoritenService().init();
|
||||
} catch (e, stack) {
|
||||
debugPrint('Start-Fehler: $e\n$stack');
|
||||
}
|
||||
|
||||
runApp(const MeloApp());
|
||||
}
|
||||
|
||||
class MeloApp extends StatelessWidget {
|
||||
const MeloApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Melo',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MeloTheme.theme,
|
||||
home: const Scaffold(body: MeloHome()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
class Song {
|
||||
final int? id;
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final String album;
|
||||
final int dauerSekunden;
|
||||
final String dateiPfad;
|
||||
final String? coverPfad;
|
||||
final int groesseBytes;
|
||||
final bool istHeruntergeladen;
|
||||
final String hinzugefuegtAm;
|
||||
final String downloadQuelle; // "local", "youtube"
|
||||
int? zuletztPosition; // Sekunden, für Wiederaufnahme
|
||||
|
||||
Song({
|
||||
this.id,
|
||||
required this.titel,
|
||||
required this.kuenstler,
|
||||
this.album = '',
|
||||
required this.dauerSekunden,
|
||||
required this.dateiPfad,
|
||||
this.coverPfad,
|
||||
this.groesseBytes = 0,
|
||||
this.istHeruntergeladen = false,
|
||||
String? hinzugefuegtAm,
|
||||
this.downloadQuelle = 'local',
|
||||
this.zuletztPosition,
|
||||
}) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String();
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'titel': titel,
|
||||
'kuenstler': kuenstler,
|
||||
'album': album,
|
||||
'dauer_sekunden': dauerSekunden,
|
||||
'datei_pfad': dateiPfad,
|
||||
'cover_pfad': coverPfad,
|
||||
'groesse_bytes': groesseBytes,
|
||||
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
|
||||
'hinzugefuegt_am': hinzugefuegtAm,
|
||||
'download_quelle': downloadQuelle,
|
||||
'zuletzt_position': zuletztPosition,
|
||||
};
|
||||
|
||||
factory Song.fromMap(Map<String, dynamic> m) => Song(
|
||||
id: m['id'] as int?,
|
||||
titel: m['titel'] as String,
|
||||
kuenstler: m['kuenstler'] as String,
|
||||
album: m['album'] as String? ?? '',
|
||||
dauerSekunden: m['dauer_sekunden'] as int,
|
||||
dateiPfad: m['datei_pfad'] as String,
|
||||
coverPfad: m['cover_pfad'] as String?,
|
||||
groesseBytes: m['groesse_bytes'] as int? ?? 0,
|
||||
istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1,
|
||||
hinzugefuegtAm: m['hinzugefuegt_am'] as String?,
|
||||
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
||||
zuletztPosition: m['zuletzt_position'] as int?,
|
||||
);
|
||||
|
||||
String get dauerFormatiert {
|
||||
final min = dauerSekunden ~/ 60;
|
||||
final sek = dauerSekunden % 60;
|
||||
return '$min:${sek.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String get groesseFormatiert {
|
||||
if (groesseBytes < 1048576) return '${(groesseBytes / 1024).toStringAsFixed(0)} KB';
|
||||
return '${(groesseBytes / 1048576).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
class Tag {
|
||||
final int? id;
|
||||
final String name;
|
||||
final String? icon;
|
||||
final String? farbeHex;
|
||||
|
||||
Tag({this.id, required this.name, this.icon, this.farbeHex});
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'icon': icon,
|
||||
'farbe_hex': farbeHex,
|
||||
};
|
||||
|
||||
factory Tag.fromMap(Map<String, dynamic> m) => Tag(
|
||||
id: m['id'] as int?,
|
||||
name: m['name'] as String,
|
||||
icon: m['icon'] as String?,
|
||||
farbeHex: m['farbe_hex'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
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 '../models/song.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';
|
||||
|
||||
class MeloHome extends StatefulWidget {
|
||||
const MeloHome({super.key});
|
||||
|
||||
@override
|
||||
State<MeloHome> createState() => _MeloHomeState();
|
||||
}
|
||||
|
||||
class _MeloHomeState extends State<MeloHome> {
|
||||
final PlayerService _player = PlayerService();
|
||||
final DbHelper _db = DbHelper();
|
||||
final FavoritenService _favoriten = FavoritenService();
|
||||
final MusikScanner _scanner = MusikScanner();
|
||||
final DownloadService _downloader = DownloadService();
|
||||
|
||||
List<Song> _songs = [];
|
||||
String _aktiverTag = 'Alle';
|
||||
bool _ladt = true;
|
||||
Set<int> _favoritenIds = {};
|
||||
|
||||
final List<Map<String, String>> _beispielTags = [
|
||||
{'name': 'Alle', 'icon': ''},
|
||||
{'name': '❤️ Für uns', 'icon': '❤️'},
|
||||
{'name': 'Nightcore', 'icon': '⚡'},
|
||||
{'name': 'Traurig', 'icon': '😢'},
|
||||
{'name': 'Party', 'icon': '🎉'},
|
||||
{'name': 'Mitsingen', 'icon': '🎤'},
|
||||
{'name': '2000er', 'icon': '📀'},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ladeSongs();
|
||||
}
|
||||
|
||||
List<Song> get _gefilterteSongs {
|
||||
if (_aktiverTag == 'Alle') return _songs;
|
||||
return _songs.where((s) =>
|
||||
s.titel.contains(_aktiverTag) ||
|
||||
s.kuenstler.contains(_aktiverTag)
|
||||
).toList();
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _zeigeSuche() async {
|
||||
final controller = TextEditingController();
|
||||
final ergebnis = await showDialog<String>(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
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();
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: Text('🔍 ${gefiltert.length} Treffer', style: const TextStyle(color: Colors.white)),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: gefiltert.isEmpty
|
||||
? const Center(child: Text('Keine Treffer', style: TextStyle(color: Colors.grey)))
|
||||
: ListView.builder(
|
||||
itemCount: gefiltert.length,
|
||||
itemBuilder: (_, i) => ListTile(
|
||||
leading: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
|
||||
),
|
||||
child: const Center(child: Text('♪', style: TextStyle(fontSize: 14, color: Colors.white54))),
|
||||
),
|
||||
title: Text(gefiltert[i].titel, style: const TextStyle(color: Colors.white)),
|
||||
subtitle: Text(gefiltert[i].kuenstler, style: const TextStyle(color: Colors.grey)),
|
||||
onTap: () { Navigator.pop(ctx); _spieleSong(gefiltert[i]); },
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanMusik() async {
|
||||
final erlaubt = await _scanner.frageSpeicherZugriff();
|
||||
if (!erlaubt) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bitte Speicherzugriff erlauben')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _scanner.scanneMusikOrdner();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Scannen fertig: ${_scanner.anzahlNeueSongs} neue Songs gefunden')),
|
||||
);
|
||||
await _ladeSongs();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _zeigeDownloadDialog() async {
|
||||
final controller = TextEditingController();
|
||||
final url = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: const Text('⬇ YouTube-Link einfügen', style: TextStyle(color: Colors.white)),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'https://youtube.com/watch?v=...',
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('Download', style: TextStyle(color: MeloTheme.rot)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
Timer? timer;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
timer ??= Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
if (ctx.mounted) setDialogState(() {});
|
||||
});
|
||||
|
||||
_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();
|
||||
});
|
||||
} else if (ctx.mounted) {
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final fehler = _downloader.fehler;
|
||||
final fortschritt = _downloader.fortschritt;
|
||||
final statusText = fehler ?? _downloader.aktuellerTitel ?? 'Song wird heruntergeladen...';
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: const Text('⬇ Download', style: TextStyle(color: Colors.white)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (fehler == null)
|
||||
LinearProgressIndicator(
|
||||
value: fortschritt > 0 ? fortschritt : null,
|
||||
color: MeloTheme.rot,
|
||||
)
|
||||
else
|
||||
const Icon(Icons.error, color: Colors.red, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(statusText,
|
||||
style: TextStyle(fontSize: 13, color: fehler != null ? Colors.red : Colors.white70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (fortschritt > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('${(fortschritt * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _spieleSong(Song song) {
|
||||
_player.setWarteschlange(_songs,
|
||||
startIndex: _songs.indexWhere((s) => s.id == song.id));
|
||||
_player.spiele(song);
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
),
|
||||
TagLeiste(
|
||||
tags: _beispielTags,
|
||||
aktiverTag: _aktiverTag,
|
||||
onTagSelected: (tag) => setState(() => _aktiverTag = tag),
|
||||
),
|
||||
Expanded(child: _songListe()),
|
||||
const MiniPlayer(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _bottomNav(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _songListe() {
|
||||
final songs = _gefilterteSongs;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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)),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _scanMusik,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MeloTheme.dunkel2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.refresh, size: 12, color: MeloTheme.rot),
|
||||
SizedBox(width: 4),
|
||||
Text('Scannen', style: TextStyle(fontSize: 11, color: MeloTheme.rot)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
|
||||
itemCount: songs.length,
|
||||
itemBuilder: (_, i) => SongTile(
|
||||
song: songs[i],
|
||||
istFavorit: songs[i].id != null && _favoritenIds.contains(songs[i].id),
|
||||
onFavoriteToggle: _favoritenUmschalten,
|
||||
onPlay: _spieleSong,
|
||||
onMetadataChanged: _ladeSongs,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomNav() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: MeloTheme.dunkel1)),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: MeloTheme.schwarz,
|
||||
selectedItemColor: MeloTheme.rot,
|
||||
unselectedItemColor: MeloTheme.textSekundaer,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.download, size: 22), label: 'Downloads'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
class DownloadService {
|
||||
static final DownloadService _instanz = DownloadService._();
|
||||
factory DownloadService() => _instanz;
|
||||
DownloadService._();
|
||||
|
||||
final YoutubeExplode _yt = YoutubeExplode();
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
bool _ladt = false;
|
||||
double _fortschritt = 0;
|
||||
String? _aktuellerTitel;
|
||||
String? _fehler;
|
||||
|
||||
bool get ladt => _ladt;
|
||||
double get fortschritt => _fortschritt;
|
||||
String? get aktuellerTitel => _aktuellerTitel;
|
||||
String? get fehler => _fehler;
|
||||
|
||||
void _resetStatus() {
|
||||
_ladt = true;
|
||||
_fortschritt = 0;
|
||||
_aktuellerTitel = null;
|
||||
_fehler = null;
|
||||
}
|
||||
|
||||
Future<Song?> downloadVonUrl(String url) async {
|
||||
_resetStatus();
|
||||
|
||||
try {
|
||||
// URL-Validierung
|
||||
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
||||
_fehler = 'Keine gültige YouTube-URL';
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
String titel = '';
|
||||
String kuenstler = '';
|
||||
int dauer = 0;
|
||||
|
||||
// Video-Info mit Timeout
|
||||
_aktuellerTitel = 'YouTube wird kontaktiert...';
|
||||
try {
|
||||
final video = await _yt.videos.get(url).timeout(const Duration(seconds: 15));
|
||||
titel = video.title;
|
||||
kuenstler = video.author;
|
||||
dauer = video.duration?.inSeconds ?? 0;
|
||||
_aktuellerTitel = 'Video gefunden: $titel';
|
||||
} catch (e) {
|
||||
_fehler = 'Timeout/Fehler bei Video-Info: ${e.toString()}';
|
||||
debugPrint('Video-Info Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Manifest abrufen
|
||||
_aktuellerTitel = 'Audio-Stream wird ermittelt... (2/4)';
|
||||
AudioStreamInfo? audio;
|
||||
try {
|
||||
final manifest = await _yt.videos.streamsClient
|
||||
.getManifest(url).timeout(const Duration(seconds: 15));
|
||||
final streams = manifest.audioOnly.toList();
|
||||
if (streams.isEmpty) {
|
||||
_fehler = 'Kein Audio-Stream verfügbar';
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
audio = streams.reduce((a, b) => a.bitrate.bitsPerSecond > b.bitrate.bitsPerSecond ? a : b);
|
||||
} catch (e) {
|
||||
_fehler = 'Stream-Fehler: ${e.toString()}';
|
||||
debugPrint('Stream Manifest Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Zielpfad
|
||||
_aktuellerTitel = 'Speicher wird vorbereitet... (3/4)';
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musikDir = Directory('${dir.path}/music');
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp4';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
// Download
|
||||
_aktuellerTitel = 'Lade herunter... (4/4)';
|
||||
try {
|
||||
final fileStream = _yt.videos.streamsClient.get(audio);
|
||||
final file = File(dateiPfad);
|
||||
final sink = file.openWrite();
|
||||
int downloaded = 0;
|
||||
final total = audio.size.totalBytes;
|
||||
await for (final chunk in fileStream) {
|
||||
sink.add(chunk);
|
||||
downloaded += chunk.length;
|
||||
_fortschritt = downloaded / total;
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
_fehler = 'Download-Fehler: ${e.toString()}';
|
||||
debugPrint('Download Stream Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Song speichern
|
||||
final song = Song(
|
||||
titel: titel,
|
||||
kuenstler: kuenstler,
|
||||
album: 'YouTube',
|
||||
dauerSekunden: dauer,
|
||||
dateiPfad: dateiPfad,
|
||||
groesseBytes: await File(dateiPfad).length(),
|
||||
istHeruntergeladen: true,
|
||||
downloadQuelle: 'youtube',
|
||||
);
|
||||
await _db.songEinfuegen(song);
|
||||
|
||||
_ladt = false;
|
||||
_aktuellerTitel = '✅ ${song.titel} heruntergeladen';
|
||||
return song;
|
||||
} catch (e) {
|
||||
_fehler = 'Fehler: ${e.toString()}';
|
||||
debugPrint('Download allgemeiner Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_yt.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import '../database/db_helper.dart';
|
||||
import '../models/song.dart';
|
||||
|
||||
class FavoritenService {
|
||||
static final FavoritenService _instanz = FavoritenService._();
|
||||
factory FavoritenService() => _instanz;
|
||||
FavoritenService._();
|
||||
|
||||
final DbHelper _db = DbHelper();
|
||||
int? _favoritenPlaylistId;
|
||||
|
||||
int? get favoritenId => _favoritenPlaylistId;
|
||||
|
||||
Future<void> init() async {
|
||||
final playlists = await _db.allePlaylists();
|
||||
final vorhanden = playlists.where((p) => p['name'] == '⭐ Favoriten').toList();
|
||||
if (vorhanden.isNotEmpty) {
|
||||
_favoritenPlaylistId = vorhanden.first['id'] as int;
|
||||
} else {
|
||||
_favoritenPlaylistId = await _db.playlistErstellen('⭐ Favoriten');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> istFavorit(int songId) async {
|
||||
if (_favoritenPlaylistId == null) return false;
|
||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
return songs.any((s) => s.id == songId);
|
||||
}
|
||||
|
||||
Future<void> umschalten(int songId) async {
|
||||
if (_favoritenPlaylistId == null) return;
|
||||
if (await istFavorit(songId)) {
|
||||
await _db.songAusPlaylistEntfernen(_favoritenPlaylistId!, songId);
|
||||
} else {
|
||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
await _db.songZurPlaylist(_favoritenPlaylistId!, songId, songs.length);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Song>> alleFavoriten() async {
|
||||
if (_favoritenPlaylistId == null) return [];
|
||||
return _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
}
|
||||
|
||||
Future<Set<int>> favoritenIds() async {
|
||||
if (_favoritenPlaylistId == null) return {};
|
||||
final d = await _db.db;
|
||||
final rows = await d.rawQuery(
|
||||
'SELECT song_id FROM playlist_songs WHERE playlist_id = ?',
|
||||
[_favoritenPlaylistId],
|
||||
);
|
||||
return rows.map((r) => r['song_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
class MusikScanner {
|
||||
static final MusikScanner _instanz = MusikScanner._();
|
||||
factory MusikScanner() => _instanz;
|
||||
MusikScanner._();
|
||||
|
||||
final DbHelper _db = DbHelper();
|
||||
int _neueSongs = 0;
|
||||
|
||||
int get anzahlNeueSongs => _neueSongs;
|
||||
|
||||
Future<bool> frageSpeicherZugriff() async {
|
||||
final status = await Permission.audio.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
Future<List<Song>> scanneMusikOrdner() async {
|
||||
_neueSongs = 0;
|
||||
final gefunden = <Song>[];
|
||||
final pfade = await _sammleMusikPfade();
|
||||
|
||||
for (final pfad in pfade) {
|
||||
try {
|
||||
final file = File(pfad);
|
||||
if (!await file.exists()) continue;
|
||||
final stat = await file.stat();
|
||||
gefunden.add(Song(
|
||||
titel: _dateiNameOhneEndung(pfad),
|
||||
kuenstler: 'Unbekannt',
|
||||
album: '',
|
||||
dauerSekunden: await _ermittleDauer(pfad),
|
||||
dateiPfad: pfad,
|
||||
coverPfad: null,
|
||||
groesseBytes: stat.size,
|
||||
istHeruntergeladen: true,
|
||||
downloadQuelle: 'local',
|
||||
));
|
||||
} catch (_) {
|
||||
// Datei nicht lesbar → überspringen
|
||||
}
|
||||
}
|
||||
|
||||
// In DB speichern
|
||||
final vorhandene = await _db.alleSongs();
|
||||
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
|
||||
|
||||
final neue = gefunden.where((s) => !vorhandenePfade.contains(s.dateiPfad)).toList();
|
||||
if (neue.isNotEmpty) {
|
||||
await _db.songsEinfuegen(neue);
|
||||
_neueSongs = neue.length;
|
||||
}
|
||||
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
Future<List<String>> _sammleMusikPfade() async {
|
||||
final pfade = <String>{};
|
||||
|
||||
// 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)
|
||||
try {
|
||||
final extern = await getExternalStorageDirectory();
|
||||
if (extern != null) {
|
||||
ordner.add(extern.path);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 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);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return pfade.toList();
|
||||
}
|
||||
|
||||
Future<List<String>> _scanneViaMediaStore() async {
|
||||
// Nutzt Android's MediaStore Query
|
||||
// Wird über Method Channel in native Android implementiert
|
||||
// Für v1: Fallback auf Dateisystem-Suche
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> _durchsucheOrdner(Directory dir, Set<String> pfade, {int tiefe = 0}) async {
|
||||
if (tiefe > 4) return;
|
||||
try {
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
final ext = entity.path.toLowerCase();
|
||||
if (ext.endsWith('.mp3') || ext.endsWith('.m4a') ||
|
||||
ext.endsWith('.flac') || ext.endsWith('.wav') ||
|
||||
ext.endsWith('.aac') || ext.endsWith('.ogg')) {
|
||||
pfade.add(entity.path);
|
||||
}
|
||||
} else if (entity is Directory) {
|
||||
await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<int> _ermittleDauer(String pfad) async {
|
||||
try {
|
||||
final player = AudioPlayer();
|
||||
await player.setFilePath(pfad);
|
||||
final dauer = player.duration;
|
||||
await player.dispose();
|
||||
return dauer?.inSeconds ?? 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _dateiNameOhneEndung(String pfad) {
|
||||
final name = pfad.split('/').last;
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../models/song.dart';
|
||||
|
||||
class PlayerService {
|
||||
static final PlayerService _instanz = PlayerService._();
|
||||
factory PlayerService() => _instanz;
|
||||
PlayerService._();
|
||||
|
||||
AudioPlayer? _player;
|
||||
final List<Song> _warteschlange = [];
|
||||
int _aktuellerIndex = -1;
|
||||
StreamSubscription<PlayerState>? _autoNextSub;
|
||||
|
||||
AudioPlayer get _p {
|
||||
if (_player == null) {
|
||||
_player = AudioPlayer();
|
||||
_autoNextSub = _player!.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
naechstes();
|
||||
}
|
||||
});
|
||||
}
|
||||
return _player!;
|
||||
}
|
||||
|
||||
Song? get aktuellerSong => _aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length
|
||||
? _warteschlange[_aktuellerIndex] : null;
|
||||
bool get spieltAb => _player?.playing ?? false;
|
||||
Duration get position => _player?.position ?? Duration.zero;
|
||||
Duration get dauer => _player?.duration ?? Duration.zero;
|
||||
Stream<Duration> get positionStream => _p.positionStream;
|
||||
Stream<PlayerState> get stateStream => _p.playerStateStream;
|
||||
|
||||
final StreamController<Song?> _songWechsel = StreamController.broadcast();
|
||||
Stream<Song?> get onSongWechsel => _songWechsel.stream;
|
||||
|
||||
Future<void> spiele(Song song, {int position = 0}) async {
|
||||
_aktuellerIndex = _warteschlange.indexWhere((s) => s.id == song.id);
|
||||
if (_aktuellerIndex < 0) {
|
||||
_warteschlange.add(song);
|
||||
_aktuellerIndex = _warteschlange.length - 1;
|
||||
}
|
||||
try {
|
||||
await _p.setFilePath(song.dateiPfad);
|
||||
if (position > 0) await _p.seek(Duration(seconds: position));
|
||||
await _p.play();
|
||||
_songWechsel.add(song);
|
||||
} catch (e) {
|
||||
debugPrint('Fehler beim Abspielen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playPause() async {
|
||||
if (_player == null) return;
|
||||
if (_p.playing) {
|
||||
await _p.pause();
|
||||
} else if (aktuellerSong != null) {
|
||||
await _p.play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> vorheriges() async {
|
||||
if (_player == null || aktuellerSong == null) return;
|
||||
final pos = _p.position;
|
||||
if (pos.inSeconds > 5) {
|
||||
await _p.seek(Duration.zero);
|
||||
} else {
|
||||
final neuerIndex = _aktuellerIndex - 1;
|
||||
if (neuerIndex >= 0) {
|
||||
await spiele(_warteschlange[neuerIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> naechstes() async {
|
||||
final neuerIndex = _aktuellerIndex + 1;
|
||||
if (neuerIndex < _warteschlange.length) {
|
||||
await spiele(_warteschlange[neuerIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void setWarteschlange(List<Song> songs, {int startIndex = 0}) {
|
||||
_warteschlange.clear();
|
||||
_warteschlange.addAll(songs);
|
||||
_aktuellerIndex = startIndex;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_autoNextSub?.cancel();
|
||||
_player?.dispose();
|
||||
_songWechsel.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MeloTheme {
|
||||
static const Color rot = Color(0xFFCC0000);
|
||||
static const Color rotHell = Color(0x33CC0000);
|
||||
static const Color schwarz = Color(0xFF0D0D0D);
|
||||
static const Color dunkel1 = Color(0xFF1A1A1A);
|
||||
static const Color dunkel2 = Color(0xFF2A2A2A);
|
||||
static const Color textPrimaer = Color(0xFFFFFFFF);
|
||||
static const Color textSekundaer = Color(0xFF888888);
|
||||
|
||||
static ThemeData get theme => ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: schwarz,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: rot,
|
||||
secondary: rot,
|
||||
surface: schwarz,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: schwarz,
|
||||
foregroundColor: textPrimaer,
|
||||
elevation: 0,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: schwarz,
|
||||
selectedItemColor: rot,
|
||||
unselectedItemColor: textSekundaer,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: dunkel1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: dunkel1,
|
||||
selectedColor: rot,
|
||||
labelStyle: const TextStyle(color: textPrimaer),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MeloHeader extends StatelessWidget {
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
const MeloHeader({super.key, required this.onDownload, required this.onSearch});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: [Colors.white, MeloTheme.rot],
|
||||
).createShader(bounds),
|
||||
child: const Text('Melo', style: TextStyle(
|
||||
fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
),
|
||||
const Text('Deine Musik. Deine Art.', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
),
|
||||
Row(children: [
|
||||
_btn(Icons.download, onDownload),
|
||||
const SizedBox(width: 8),
|
||||
_btn(Icons.search, onSearch),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap) {
|
||||
return Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: MeloTheme.dunkel1,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 18, color: MeloTheme.rot),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MetadatenDialog extends StatefulWidget {
|
||||
final Song song;
|
||||
const MetadatenDialog({super.key, required this.song});
|
||||
|
||||
@override
|
||||
State<MetadatenDialog> createState() => _MetadatenDialogState();
|
||||
}
|
||||
|
||||
class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
late TextEditingController _titelCtrl;
|
||||
late TextEditingController _kuenstlerCtrl;
|
||||
late TextEditingController _albumCtrl;
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titelCtrl = TextEditingController(text: widget.song.titel);
|
||||
_kuenstlerCtrl = TextEditingController(text: widget.song.kuenstler);
|
||||
_albumCtrl = TextEditingController(text: widget.song.album);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titelCtrl.dispose();
|
||||
_kuenstlerCtrl.dispose();
|
||||
_albumCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: const Text('✏️ Metadaten', style: TextStyle(color: Colors.white, fontSize: 18)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_field('Titel', _titelCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Künstler', _kuenstlerCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Album', _albumCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen', style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _speichern(),
|
||||
child: const Text('Speichern', style: TextStyle(color: MeloTheme.rot)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl) {
|
||||
return TextField(
|
||||
controller: ctrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: Colors.grey),
|
||||
border: const OutlineInputBorder(),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: MeloTheme.rot),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _speichern() async {
|
||||
final id = widget.song.id;
|
||||
if (id == null) {
|
||||
if (mounted) Navigator.pop(context, false);
|
||||
return;
|
||||
}
|
||||
final titel = _titelCtrl.text.trim();
|
||||
final kuenstler = _kuenstlerCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
if (titel.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Titel darf nicht leer sein')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.metadatenAktualisieren(
|
||||
id,
|
||||
titel: titel,
|
||||
kuenstler: kuenstler,
|
||||
album: album,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../services/player_service.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MiniPlayer extends StatefulWidget {
|
||||
const MiniPlayer({super.key});
|
||||
|
||||
@override
|
||||
State<MiniPlayer> createState() => _MiniPlayerState();
|
||||
}
|
||||
|
||||
class _MiniPlayerState extends State<MiniPlayer> {
|
||||
final PlayerService _player = PlayerService();
|
||||
Duration _position = Duration.zero;
|
||||
Duration _dauer = Duration.zero;
|
||||
bool _spielt = false;
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<PlayerState>? _stateSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_posSub = _player.positionStream.listen((pos) {
|
||||
if (mounted) {
|
||||
setState(() => _position = pos);
|
||||
}
|
||||
});
|
||||
_stateSub = _player.stateStream.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_spielt = state.playing;
|
||||
_dauer = state.processingState == ProcessingState.ready
|
||||
? (_player.dauer) : Duration.zero;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_posSub?.cancel();
|
||||
_stateSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final song = _player.aktuellerSong;
|
||||
if (song == null) return const SizedBox.shrink();
|
||||
|
||||
final progress = _dauer.inSeconds > 0
|
||||
? _position.inSeconds / _dauer.inSeconds : 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A0A0A),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF2A1515)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
color: MeloTheme.rot,
|
||||
child: const Center(child: Text('♪', style: TextStyle(fontSize: 16))),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Titel + Künstler
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(song.titel, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
Text(song.kuenstler, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Steuerung
|
||||
_btn(Icons.skip_previous, _player.vorheriges),
|
||||
_playBtn(),
|
||||
_btn(Icons.skip_next, _player.naechstes),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Fortschritt
|
||||
if (_dauer.inSeconds > 0) Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: const Color(0xFF2A2A2A),
|
||||
valueColor: const AlwaysStoppedAnimation(MeloTheme.rot),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _playBtn() {
|
||||
return Material(
|
||||
color: MeloTheme.rot,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: _player.playPause,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(_spielt ? Icons.pause : Icons.play_arrow, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/song.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
import 'metadaten_dialog.dart';
|
||||
|
||||
class SongTile extends StatelessWidget {
|
||||
final Song song;
|
||||
final bool istFavorit;
|
||||
final ValueChanged<Song> onFavoriteToggle;
|
||||
final ValueChanged<Song> onPlay;
|
||||
final VoidCallback onMetadataChanged;
|
||||
|
||||
const SongTile({
|
||||
super.key,
|
||||
required this.song,
|
||||
required this.istFavorit,
|
||||
required this.onFavoriteToggle,
|
||||
required this.onPlay,
|
||||
required this.onMetadataChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hatDatei = song.dateiPfad.isNotEmpty;
|
||||
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)]),
|
||||
),
|
||||
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)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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: () => 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class StatistikCard extends StatelessWidget {
|
||||
final int anzahlSongs;
|
||||
final String gesamtMB;
|
||||
final int gesamtMin;
|
||||
final int anzahlFavoriten;
|
||||
|
||||
const StatistikCard({
|
||||
super.key,
|
||||
required this.anzahlSongs,
|
||||
required this.gesamtMB,
|
||||
required this.gesamtMin,
|
||||
required this.anzahlFavoriten,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A0A0A),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF2A1515)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_item('$anzahlSongs', 'Lieder'),
|
||||
_item(gesamtMB, 'MB'),
|
||||
_item('$gesamtMin', 'Min'),
|
||||
_item('$anzahlFavoriten', 'Favoriten'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(String zahl, String label) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(zahl, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: MeloTheme.rot)),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class TagLeiste extends StatelessWidget {
|
||||
final List<Map<String, String>> tags;
|
||||
final String aktiverTag;
|
||||
final ValueChanged<String> onTagSelected;
|
||||
|
||||
const TagLeiste({
|
||||
super.key,
|
||||
required this.tags,
|
||||
required this.aktiverTag,
|
||||
required this.onTagSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
child: Row(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: tags.map((tag) {
|
||||
final aktiv = aktiverTag == tag['name'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text('${tag['icon']} ${tag['name']}',
|
||||
style: TextStyle(fontSize: 13, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
|
||||
selected: aktiv,
|
||||
onSelected: (_) => onTagSelected(tag['name']!),
|
||||
selectedColor: MeloTheme.rot,
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user