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 _warteschlange = []; int _aktuellerIndex = -1; StreamSubscription? _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 _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 get positionStream => _p.positionStream; Stream get stateStream => _p.playerStateStream; final StreamController _songWechsel = StreamController.broadcast(); Stream get onSongWechsel => _songWechsel.stream; // ─── Wiedergabegeschwindigkeit (F4) ─── double _geschwindigkeit = 1.0; /// Aktuelle Wiedergabegeschwindigkeit (0.5–2.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 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.generate(_warteschlange.length, (i) => i); if (_aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length) { indices.remove(_aktuellerIndex); } indices.shuffle(Random()); _shuffleReihenfolge.addAll(indices); } Future 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 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 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 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 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 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 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 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 get warteschlange => List.unmodifiable(_warteschlange); /// Index des aktuell spielenden Songs in der Warteschlange. int get aktuellerIndex => _aktuellerIndex; /// Die tatsächliche Wiedergabe-Reihenfolge — beginnend mit dem aktuell /// spielenden Song (Position 0). NICHT die Bibliotheks-Reihenfolge: /// - Normal: Warteschlange ab dem aktuellen Index /// - Shuffle: aktuelle Shuffle-Position + Rest der Zufallsreihenfolge List get wiedergabeReihenfolge { final ergebnis = []; if (_warteschlange.isEmpty) return List.unmodifiable(ergebnis); if (_aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length) { ergebnis.add(_warteschlange[_aktuellerIndex]); } if (_zufallsmodus) { if (_shuffleReihenfolge.isNotEmpty) { // _shufflePos == -1 → Shuffle gerade eingeschaltet, aktueller Song // ist noch nicht in der Reihenfolge → ab Position 0 auflisten final start = _shufflePos >= 0 ? _shufflePos + 1 : 0; for (var i = start; i < _shuffleReihenfolge.length; i++) { final idx = _shuffleReihenfolge[i]; if (idx >= 0 && idx < _warteschlange.length) { ergebnis.add(_warteschlange[idx]); } } } } else { for (var i = _aktuellerIndex + 1; i < _warteschlange.length; i++) { ergebnis.add(_warteschlange[i]); } } return List.unmodifiable(ergebnis); } /// Verschiebt einen Eintrag in der tatsächlichen Wiedergabe-Reihenfolge /// (Drag-and-Drop im Queue-Sheet). Position 0 = aktuell spielender Song /// (bleibt oben und kann nicht verschoben werden). /// - Normal: verschiebt in der Warteschlange (ab dem aktuellen Index) /// - Shuffle: verschiebt in der Zufallsreihenfolge — das ändert die echte /// Abspiel-Reihenfolge, ohne die Bibliotheks-Liste anzufassen void verschiebeInWiedergabeReihenfolge(int von, int nach) { if (von <= 0) return; // aktueller Song bleibt an Position 0 if (nach <= 0) nach = 1; // nie vor den aktuellen Song if (von == nach) return; if (_warteschlange.isEmpty) return; if (_zufallsmodus) { final start = _shufflePos >= 0 ? _shufflePos + 1 : 0; final vonShuffle = start + von - 1; final nachShuffle = start + nach - 1; if (vonShuffle < 0 || vonShuffle >= _shuffleReihenfolge.length) return; final item = _shuffleReihenfolge.removeAt(vonShuffle); final ziel = nachShuffle > _shuffleReihenfolge.length ? _shuffleReihenfolge.length : nachShuffle; _shuffleReihenfolge.insert(ziel, item); notifyListeners(); return; } final vonQueue = _aktuellerIndex + von; final nachQueue = _aktuellerIndex + nach; if (vonQueue < 0 || vonQueue >= _warteschlange.length) return; verschiebeInWarteschlange(vonQueue, nachQueue); } /// 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 _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'); } } }