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/services/player_service.dart
T

527 lines
17 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:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
import 'melo_logger.dart';
/// Wiederhol-Modi für die Warteschlange.
enum Wiederholmodus { aus, titel, playlist }
class PlayerService extends ChangeNotifier {
static final PlayerService _instanz = PlayerService._();
factory PlayerService() => _instanz;
PlayerService._();
AudioPlayer? _player;
final List<Song> _warteschlange = [];
int _aktuellerIndex = -1;
StreamSubscription<PlayerState>? _autoNextSub;
// ─── Zufallsmodus (Shuffle) ───
bool _zufallsmodus = false;
/// Zufällige Reihenfolge der Warteschlangen-Indizes (ohne den aktuellen Song).
/// Der aktuelle Song erscheint erst wieder, wenn er manuell gewählt wird.
final List<int> _shuffleReihenfolge = [];
/// Position des zuletzt gespielten Songs in [_shuffleReihenfolge] (-1 = noch nichts).
int _shufflePos = -1;
// ─── Wiederholmodus (Repeat) ───
Wiederholmodus _wiederholmodus = Wiederholmodus.aus;
// ─── Sleep-Timer ───
Timer? _sleepTimer;
DateTime? _sleepEnde;
AudioPlayer get _p {
if (_player == null) {
_player = AudioPlayer();
_autoNextSub = _player!.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
naechstes();
} else if (state.processingState == ProcessingState.idle &&
_aktuellerIndex >= 0 &&
_aktuellerIndex < _warteschlange.length) {
final song = _warteschlange[_aktuellerIndex];
// Nur markieren wenn kein Stream (Stream-Fehler sind oft temporär)
if (song.streamUrl == null || song.streamUrl!.isEmpty) {
_markiereAlsKorrupt(song);
}
}
});
}
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;
// ─── Wiedergabegeschwindigkeit (F4) ───
double _geschwindigkeit = 1.0;
/// Aktuelle Wiedergabegeschwindigkeit (0.52.0, Default 1.0).
double get geschwindigkeit => _geschwindigkeit;
/// Setzt die Wiedergabegeschwindigkeit (just_audio `setSpeed`) und merkt
/// sie sich, damit [spiele] sie auf jede neue Quelle anwendet (just_audio
/// setzt die Geschwindigkeit bei `setUrl`/`setFilePath` auf 1.0 zurück).
Future<void> setGeschwindigkeit(double wert) async {
_geschwindigkeit = wert.clamp(0.5, 2.0).toDouble();
if (_player != null) {
try {
await _p.setSpeed(_geschwindigkeit);
} catch (e) {
debugPrint('setSpeed fehlgeschlagen: $e');
MeloLogger().fehler('player_speed', e);
}
}
notifyListeners();
}
// ─── Zufallsmodus (Shuffle) ───
bool get zufallsmodus => _zufallsmodus;
void setZufallsmodus(bool wert) {
if (_zufallsmodus == wert) return;
_zufallsmodus = wert;
if (wert) _baueShuffleReihenfolge();
notifyListeners();
}
// ─── Wiederholmodus (Repeat) ───
Wiederholmodus get wiederholmodus => _wiederholmodus;
void setWiederholmodus(Wiederholmodus modus) {
if (_wiederholmodus == modus) return;
_wiederholmodus = modus;
notifyListeners();
}
/// Baut die Zufallsreihenfolge neu: alle Indizes außer dem aktuellen Song, gemischt.
/// Bei einer Einzel-Song-Queue kommt der Song nur bei Repeat-playlist in die
/// Reihenfolge — sonst ist die Queue nach dem Song zu Ende (naechsterIndex → null).
void _baueShuffleReihenfolge() {
_shuffleReihenfolge.clear();
_shufflePos = -1;
if (_warteschlange.isEmpty) return;
if (_warteschlange.length == 1) {
if (_wiederholmodus == Wiederholmodus.playlist) {
_shuffleReihenfolge.add(0);
}
return;
}
final indices = List<int>.generate(_warteschlange.length, (i) => i);
if (_aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length) {
indices.remove(_aktuellerIndex);
}
indices.shuffle(Random());
_shuffleReihenfolge.addAll(indices);
}
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;
}
// Shuffle-Position synchron halten (Song kann über Queue-UI/Manuell gewählt sein)
if (_zufallsmodus) {
final pos = _shuffleReihenfolge.indexOf(_aktuellerIndex);
if (pos < 0) {
_baueShuffleReihenfolge();
} else {
_shufflePos = pos;
}
}
try {
if (song.streamUrl != null && song.streamUrl!.isNotEmpty) {
// Streaming vom Server
await _p.setUrl(song.streamUrl!);
} else if (song.dateiPfad.isNotEmpty && await File(song.dateiPfad).exists()) {
// Lokale Datei
await _p.setFilePath(song.dateiPfad);
} else {
MeloLogger().fehler('player_keine_quelle', 'Keine gültige Quelle für: ${song.titel}');
return;
}
if (position > 0) await _p.seek(Duration(seconds: position));
await _p.play();
// Wiedergabegeschwindigkeit aus der Einstellung anwenden (just_audio
// setzt sie bei jeder neuen Quelle zurück auf 1.0)
if (_geschwindigkeit != 1.0) {
try {
await _p.setSpeed(_geschwindigkeit);
} catch (e) {
debugPrint('setSpeed fehlgeschlagen: $e');
}
}
_songWechsel.add(song);
notifyListeners();
// Abspiel-Historie für den Jahres-Recap registrieren
if (song.id != null) {
try {
await DbHelper().abspielRegistrieren(song.id!);
} catch (e) {
debugPrint('Abspiel-Historie fehlgeschlagen: $e');
}
}
} catch (e) {
debugPrint('Fehler beim Abspielen: $e');
MeloLogger().fehler('player_abspiel_fehler', e);
// Song als korrupt markieren wenn lokale Datei
if (song.streamUrl == null || song.streamUrl!.isEmpty) {
_markiereAlsKorrupt(song);
}
}
}
Future<void> playPause() async {
if (_player == null) return;
try {
if (_p.playing) {
await _p.pause();
} else if (aktuellerSong != null) {
await _p.play();
}
} catch (e) {
debugPrint('Play/Pause fehlgeschlagen: $e');
MeloLogger().fehler('player_playpause', e);
}
}
/// Explizit abspielen (kein Toggle) — für System-Controls (Bluetooth, etc.)
Future<void> play() async {
if (_player == null || aktuellerSong == null) return;
try {
await _p.play();
} catch (e) {
debugPrint('Play fehlgeschlagen: $e');
MeloLogger().fehler('player_play', e);
}
}
/// Explizit pausieren (kein Toggle) — für System-Controls (Bluetooth, etc.)
Future<void> pause() async {
if (_player == null) return;
try {
await _p.pause();
} catch (e) {
debugPrint('Pause fehlgeschlagen: $e');
MeloLogger().fehler('player_pause', e);
}
}
/// Nur seeken ohne Reload der Quelle
Future<void> seek(Duration position) async {
if (_player == null) return;
try {
await _p.seek(position);
} catch (e) {
// Live-Streams ohne Seek-Unterstützung werfen hier — nicht crashen
debugPrint('Seek fehlgeschlagen: $e');
MeloLogger().fehler('player_seek', e);
}
}
Future<void> vorheriges() async {
if (_player == null || aktuellerSong == null) return;
try {
final pos = _p.position;
if (pos.inSeconds > 5) {
await _p.seek(Duration.zero);
return;
}
if (_zufallsmodus && _shuffleReihenfolge.isNotEmpty) {
_shufflePos--;
if (_shufflePos < 0) _shufflePos = 0;
final idx = _shuffleReihenfolge[_shufflePos];
if (idx >= 0 && idx < _warteschlange.length) {
await spiele(_warteschlange[idx]);
}
return;
}
final neuerIndex = _aktuellerIndex - 1;
if (neuerIndex >= 0) {
await spiele(_warteschlange[neuerIndex]);
}
} catch (e) {
debugPrint('Vorheriger Song fehlgeschlagen: $e');
MeloLogger().fehler('player_vorheriges', e);
}
}
Future<void> naechstes() async {
if (_player == null || aktuellerSong == null) return;
if (_wiederholmodus == Wiederholmodus.titel) {
// Gleichen Song von vorn wiederholen — seek/play können bei nicht
// seekbaren Quellen (Live-Streams) werfen, daher abgesichert.
try {
await _p.seek(Duration.zero);
await _p.play();
} catch (e) {
debugPrint('Repeat-Titel fehlgeschlagen: $e');
MeloLogger().fehler('player_repeat_titel', e);
}
return;
}
final idx = naechsterIndex();
if (idx == null) return;
await spiele(_warteschlange[idx]);
}
/// Berechnet den Index des nächsten Songs in der Warteschlange —
/// berücksichtigt Zufallsmodus und Wiederholmodus. Gibt `null` zurück,
/// wenn die Warteschlange zu Ende ist (Repeat aus).
int? naechsterIndex() {
if (_aktuellerIndex < 0 || _warteschlange.isEmpty) return null;
if (_wiederholmodus == Wiederholmodus.titel) return _aktuellerIndex;
if (_zufallsmodus) {
_shufflePos++;
if (_shufflePos >= _shuffleReihenfolge.length) {
if (_wiederholmodus == Wiederholmodus.playlist) {
// Alle durchgespielt → neu mischen und weiter
_baueShuffleReihenfolge();
_shufflePos++;
} else {
return null;
}
}
if (_shufflePos < 0 || _shufflePos >= _shuffleReihenfolge.length) {
return null;
}
final idx = _shuffleReihenfolge[_shufflePos];
// Sicherheitsnetz: Einzel-Song-Queue (oder stale Reihenfolge) darf bei
// Repeat-aus nicht denselben Song erneut liefern — das ist Queue-Ende.
if (idx == _aktuellerIndex && _wiederholmodus == Wiederholmodus.aus) {
return null;
}
return (idx >= 0 && idx < _warteschlange.length) ? idx : null;
}
var neuerIndex = _aktuellerIndex + 1;
if (neuerIndex >= _warteschlange.length) {
if (_wiederholmodus == Wiederholmodus.playlist) {
neuerIndex = 0;
} else {
return null;
}
}
return neuerIndex;
}
void setWarteschlange(List<Song> songs, {int startIndex = 0}) {
_warteschlange.clear();
_warteschlange.addAll(songs);
_aktuellerIndex = _warteschlange.isEmpty
? -1
: startIndex.clamp(0, _warteschlange.length - 1);
_baueShuffleReihenfolge();
notifyListeners();
}
// ─── Warteschlange (Queue-UI) ───
/// Unveränderliche Kopie der Warteschlange für die Anzeige.
List<Song> get warteschlange => List.unmodifiable(_warteschlange);
/// Index des aktuell spielenden Songs in der Warteschlange.
int get aktuellerIndex => _aktuellerIndex;
/// Setzt [song] als nächstes auf die Wiedergabe — direkt nach dem aktuellen
/// Song. Ist der Song bereits in der Warteschlange, wird er dorthin VERSCHOBEN
/// statt dupliziert. Im Shuffle wird die Reihenfolge nicht neu gemischt:
/// der Song wird an die nächste Position gepinnt (Play-Next hat Priorität).
void spieleAlsNaechstes(Song song) {
if (_warteschlange.isEmpty) {
_warteschlange.add(song);
if (_zufallsmodus) _baueShuffleReihenfolge();
notifyListeners();
return;
}
if (_aktuellerIndex < 0) {
// Kein aktiver Song → ans Ende hängen
final vorhanden = _warteschlange.indexWhere((s) => s.id == song.id);
if (vorhanden < 0) _warteschlange.add(song);
if (_zufallsmodus) _baueShuffleReihenfolge();
notifyListeners();
return;
}
final ziel = _aktuellerIndex + 1;
final vorhandenerIndex = _warteschlange.indexWhere((s) => s.id == song.id);
var gepinnt = -1;
var alterIndex = -1;
var einfuegePosition = -1;
var queueGeaendert = false;
if (vorhandenerIndex >= 0 && vorhandenerIndex != _aktuellerIndex) {
// Bereits in der Queue → verschieben statt duplizieren
if (vorhandenerIndex != ziel) {
alterIndex = vorhandenerIndex;
final item = _warteschlange.removeAt(vorhandenerIndex);
einfuegePosition = vorhandenerIndex < ziel ? ziel - 1 : ziel;
_warteschlange.insert(einfuegePosition, item);
if (vorhandenerIndex < _aktuellerIndex) _aktuellerIndex -= 1;
gepinnt = einfuegePosition;
queueGeaendert = true;
} else {
gepinnt = vorhandenerIndex; // steht schon direkt nach dem aktuellen
}
} else if (vorhandenerIndex < 0) {
einfuegePosition = ziel;
_warteschlange.insert(ziel, song);
gepinnt = ziel;
queueGeaendert = true;
}
// vorhandenerIndex == _aktuellerIndex → Song läuft gerade, nichts zu tun
if (_zufallsmodus && gepinnt >= 0) {
_pinneInShuffleReihenfolge(
gepinnt: gepinnt,
alterIndex: alterIndex,
einfuegePosition: einfuegePosition,
queueGeaendert: queueGeaendert,
);
}
notifyListeners();
}
/// Korrigiert [_shuffleReihenfolge] nach einer Queue-Änderung und pinnt
/// [gepinnt] (neuer Queue-Index des Songs) als nächstes.
void _pinneInShuffleReihenfolge({
required int gepinnt,
required int alterIndex,
required int einfuegePosition,
required bool queueGeaendert,
}) {
if (queueGeaendert) {
// 1) Entfernung spiegeln (alter Index war evtl. noch in der Reihenfolge)
final entferntPos = alterIndex >= 0
? _shuffleReihenfolge.indexOf(alterIndex)
: -1;
if (entferntPos >= 0) {
_shuffleReihenfolge.removeAt(entferntPos);
if (entferntPos < _shufflePos) _shufflePos -= 1;
for (var k = 0; k < _shuffleReihenfolge.length; k++) {
if (_shuffleReihenfolge[k] > alterIndex) {
_shuffleReihenfolge[k] -= 1;
}
}
}
// 2) Einfügung spiegeln (Indizes ab der Einfügeposition rücken nach rechts)
for (var k = 0; k < _shuffleReihenfolge.length; k++) {
if (_shuffleReihenfolge[k] >= einfuegePosition) {
_shuffleReihenfolge[k] += 1;
}
}
}
// 3) Song an die Position direkt nach dem aktuellen pinnten
_shuffleReihenfolge.remove(gepinnt);
final pinPos = _shufflePos + 1 > _shuffleReihenfolge.length
? _shuffleReihenfolge.length
: _shufflePos + 1;
_shuffleReihenfolge.insert(pinPos, gepinnt);
// _shufflePos so setzen, dass der gepinnte Song als nächstes gespielt wird
_shufflePos = pinPos - 1;
}
/// Hängt [song] ans Ende der Warteschlange an.
void amEndeHinzufuegen(Song song) {
_warteschlange.add(song);
if (_zufallsmodus) _baueShuffleReihenfolge();
notifyListeners();
}
/// Verschiebt einen Eintrag in der Warteschlange (Drag-and-Drop).
/// Der aktuelle Index wird entsprechend angepasst.
void verschiebeInWarteschlange(int von, int nach) {
if (von < 0 || von >= _warteschlange.length) return;
if (nach < 0 || nach >= _warteschlange.length) return;
if (von == nach) return;
final song = _warteschlange.removeAt(von);
_warteschlange.insert(nach, song);
if (_aktuellerIndex == von) {
_aktuellerIndex = nach;
} else if (von < _aktuellerIndex && nach >= _aktuellerIndex) {
_aktuellerIndex -= 1;
} else if (von > _aktuellerIndex && nach <= _aktuellerIndex) {
_aktuellerIndex += 1;
}
if (_zufallsmodus) _baueShuffleReihenfolge();
notifyListeners();
}
// ─── Sleep-Timer ───
bool get sleepTimerAktiv => _sleepTimer != null;
/// Verbleibende Zeit bis zum automatischen Pausieren (null wenn inaktiv).
Duration? get sleepRestzeit {
final ende = _sleepEnde;
if (ende == null) return null;
final rest = ende.difference(DateTime.now());
return rest.isNegative ? Duration.zero : rest;
}
/// Startet den Sleep-Timer — nach [dauer] wird die Wiedergabe pausiert.
void setSleepTimer(Duration dauer) {
cancelSleepTimer();
_sleepEnde = DateTime.now().add(dauer);
_sleepTimer = Timer(dauer, () {
_sleepTimer = null;
_sleepEnde = null;
pause();
notifyListeners();
});
notifyListeners();
}
/// Bricht den Sleep-Timer ab.
void cancelSleepTimer() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepEnde = null;
notifyListeners();
}
@override
void dispose() {
_autoNextSub?.cancel();
_sleepTimer?.cancel();
_player?.dispose();
_player = null;
_songWechsel.close();
super.dispose();
}
/// Markiert den aktuellen Song in der DB als korrupt
Future<void> _markiereAlsKorrupt(Song song) async {
if (song.id == null) return;
try {
final db = DbHelper();
final s = await db.songNachId(song.id!);
if (s != null && !s.istKorrupt) {
await db.alsKorruptMarkieren(song.id!);
debugPrint('⚠️ Song als korrupt markiert: ${song.titel}');
MeloLogger().fehler('korrupt_markiert', 'Playback-Fehler: ${song.titel} (${song.dateiPfad})');
}
} catch (e) {
debugPrint('Korrupt-Markierung fehlgeschlagen: $e');
}
}
}