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
Dustin 8e9d9efc64 v2.49.5 — Play-Next verschiebt statt zu duplizieren + Shuffle-Priorität
## Fix (Code-Review Finding 3, MED)
- spieleAlsNaechstes: Song bereits in der Queue wird an Position nach dem
  aktuellen VERSCHOBEN (inkl. _aktuellerIndex-Anpassung), keine Kopie mehr
- Shuffle: keine Neu-Mischung nach Play-Next — Song wird via
  _pinneInShuffleReihenfolge an die nächste Position gepinnt (Index-
  Korrektur für Verschiebungen, _shufflePos-Nachführung am Reihenende)
- 3 Tests ergänzt: Verschieben statt Duplizieren, Song von vor dem
  aktuellen, Shuffle-Pin (kein Duplikat + naechsterIndex == gepinnter Song)
2026-08-04 16:47:29 +02:00

463 lines
15 KiB
Dart

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;
// ─── 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();
_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;
if (_p.playing) {
await _p.pause();
} else if (aktuellerSong != null) {
await _p.play();
}
}
/// Explizit abspielen (kein Toggle) — für System-Controls (Bluetooth, etc.)
Future<void> play() async {
if (_player == null || aktuellerSong == null) return;
await _p.play();
}
/// Explizit pausieren (kein Toggle) — für System-Controls (Bluetooth, etc.)
Future<void> pause() async {
if (_player == null) return;
await _p.pause();
}
/// Nur seeken ohne Reload der Quelle
Future<void> seek(Duration position) async {
if (_player == null) return;
await _p.seek(position);
}
Future<void> vorheriges() async {
if (_player == null || aktuellerSong == null) return;
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]);
}
}
Future<void> naechstes() async {
if (_player == null || aktuellerSong == null) return;
if (_wiederholmodus == Wiederholmodus.titel) {
// Gleichen Song von vorn wiederholen
await _p.seek(Duration.zero);
await _p.play();
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');
}
}
}