Fix: Review-Findings korrigiert (Tag-Leiste, Profil, Singleton u.a.)
CRIT: - Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats - Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr) HIGH: - CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel) - StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt) - CloudService als Singleton (konsistenter Token-Zustand in allen Services) MED/LOW: - Playlist-Erkennung nur noch via list= Parameter - Player: fehlende Quelle wird geloggt statt still - song_tile: null-ID-Guard vor Tag-Dialog - Scanner-Log mit Exception-Objekt - FavoritenService: anzahlFavoriten() fuer StatistikCard
This commit is contained in:
@@ -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<void> play() async {
|
||||
if (_player.aktuellerSong != null) {
|
||||
_playing = true;
|
||||
await _player.playPause();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
_playing = false;
|
||||
await _player.playPause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_playing = false;
|
||||
await _player.playPause();
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
controls: [MediaControl.play],
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.idle,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
_player.spiele(_player.aktuellerSong!, position: position.inSeconds);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() => _player.naechstes();
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() => _player.vorheriges();
|
||||
|
||||
// Exposed for the ViewModel
|
||||
PlayerService get player => _player;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -42,6 +42,13 @@ class FavoritenService {
|
||||
return _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
}
|
||||
|
||||
Future<int> anzahlFavoriten() async {
|
||||
if (_favoritenPlaylistId == null) await init();
|
||||
if (_favoritenPlaylistId == null) return 0;
|
||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
return songs.length;
|
||||
}
|
||||
|
||||
Future<Set<int>> favoritenIds() async {
|
||||
if (_favoritenPlaylistId == null) return {};
|
||||
final d = await _db.db;
|
||||
|
||||
@@ -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<String, dynamic> lesen(String filepath) {
|
||||
final result = <String, dynamic>{
|
||||
'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<int> bytes) {
|
||||
final end = bytes.indexWhere((b) => b == 0);
|
||||
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
|
||||
}
|
||||
|
||||
static int _synchSafeInt(List<int> bytes, int offset) {
|
||||
return (bytes[offset] << 21) | (bytes[offset + 1] << 14) | (bytes[offset + 2] << 7) | bytes[offset + 3];
|
||||
}
|
||||
|
||||
static int _frameSize(List<int> bytes, int offset) {
|
||||
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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<List<Song>> 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<int>) {
|
||||
final bytes = tags['cover'] as List<int>;
|
||||
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<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)
|
||||
// 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 = <String>[
|
||||
'/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<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)) {
|
||||
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<int> _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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -33,6 +33,11 @@ class PlaylistService {
|
||||
await _db.songAusPlaylistEntfernen(playlistId, songId);
|
||||
}
|
||||
|
||||
Future<void> reihenfolgeSpeichern(int playlistId, List<Song> songs) async {
|
||||
final songIds = songs.map((s) => s.id).whereType<int>().toList();
|
||||
await _db.playlistReihenfolgeAktualisieren(playlistId, songIds);
|
||||
}
|
||||
|
||||
Future<bool> istInPlaylist(int playlistId, int songId) async {
|
||||
final songs = await _db.songsDerPlaylist(playlistId);
|
||||
return songs.any((s) => s.id == songId);
|
||||
|
||||
Reference in New Issue
Block a user