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; // ─── 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.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(); _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; if (_p.playing) { await _p.pause(); } else if (aktuellerSong != null) { await _p.play(); } } /// Explizit abspielen (kein Toggle) — für System-Controls (Bluetooth, etc.) Future play() async { if (_player == null || aktuellerSong == null) return; await _p.play(); } /// Explizit pausieren (kein Toggle) — für System-Controls (Bluetooth, etc.) Future pause() async { if (_player == null) return; await _p.pause(); } /// Nur seeken ohne Reload der Quelle Future seek(Duration position) async { if (_player == null) return; await _p.seek(position); } Future 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 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 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; /// Fügt [song] direkt nach dem aktuellen Song ein. void spieleAlsNaechstes(Song song) { if (_aktuellerIndex < 0 || _warteschlange.isEmpty) { _warteschlange.add(song); } else { _warteschlange.insert(_aktuellerIndex + 1, song); } if (_zufallsmodus) _baueShuffleReihenfolge(); notifyListeners(); } /// 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'); } } }