## PlayerService
- Enum Wiederholmodus { aus, titel, playlist } + Getter/Setter
- naechstes(): titel → gleicher Song von vorn (seek 0 + play)
- naechsterIndex(): zentrale Berechnung des nächsten Index
- playlist: nach letztem Song wieder von vorne (auch bei Shuffle: neu mischen)
- aus: Ende der Warteschlange stoppt
- Auto-Next (ProcessingState.completed) läuft über naechstes() → Repeat wirkt auch automatisch
## MiniPlayer
- Repeat-Icon mit 3 Zuständen (repeat / repeat_one), rot wenn aktiv, Wechsel bei Tipp
Keine neuen Packages, flutter analyze 0 Issues, Tests 4/4
284 lines
8.9 KiB
Dart
284 lines
8.9 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;
|
|
|
|
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.
|
|
void _baueShuffleReihenfolge() {
|
|
_shuffleReihenfolge.clear();
|
|
_shufflePos = -1;
|
|
if (_warteschlange.isEmpty) return;
|
|
if (_warteschlange.length == 1) {
|
|
_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];
|
|
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();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_autoNextSub?.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');
|
|
}
|
|
}
|
|
}
|