Files
Melo/lib/player/audio_handler.dart
T
Hermes (Server)andClaude Opus 5 b42e045e67 Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze
Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb
nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen".

Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner:
den darf Android bei Speichernot jederzeit selbst leeren, und genau das
darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben
der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den
Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt.

- Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch
- Rueckfrage ab 30 Titeln samt Groessenschaetzung
- Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB;
  am laengsten nicht Gehoertes weicht zuerst
- Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle
  entfernbar (Schema 10)
- Wiedergabe nimmt erst den Download, dann den Zwischenspeicher

Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB)
schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den
Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server
anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien
loeschen, die die laufende Warteschlange schon als Quelle eingetragen
hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein
heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache
geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim
Aufbau der Warteschlange statt beim Abspielen gesetzt wurde.

Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji
als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im
ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings".

433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 21:31:53 +02:00

544 lines
22 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import '../library/database.dart';
import '../services/cache_manager.dart';
import '../services/download_speicher.dart';
import '../services/navidrome_service.dart';
import 'headphone_autoplay.dart';
import 'replay_gain.dart';
import 'sleep_timer.dart';
/// Entscheidet, ob eine gespeicherte Wiedergabeposition beim Laden
/// wiederhergestellt werden soll. Kein Resume bei Position 0 oder wenn die
/// Position zu nah am (bekannten) Ende des Tracks liegt — sonst würde die
/// Wiedergabe sofort wieder enden.
bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) {
if (lastPositionMs <= 0) return false;
if (trackDuration != null &&
lastPositionMs >= trackDuration.inMilliseconds - 3000) {
return false;
}
return true;
}
/// Entscheidet, ob für den Titel an [newIndex] eine Wiedergabe gezählt wird.
/// Gezählt wird jeder Titelwechsel; eine Wiederholung desselben Titels
/// (LoopMode.one) zählt bewusst nicht erneut.
bool shouldCountPlay(int? newIndex, int? lastCountedIndex) =>
newIndex != null && newIndex != lastCountedIndex;
/// Stelle, an der ein Titel eingefügt wird, damit er als Nächstes läuft:
/// direkt hinter dem laufenden. Läuft nichts, kommt er ans Ende.
int playNextIndex({required int? currentIndex, required int queueLength}) {
if (currentIndex == null) return queueLength;
return (currentIndex + 1).clamp(0, queueLength);
}
/// Entfernt den Titel an [removeIndex] aus [queue].
List<MediaItem> removeFromQueue(List<MediaItem> queue, int removeIndex) {
return List<MediaItem>.from(queue)..removeAt(removeIndex);
}
/// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits
/// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`)
/// und passt damit zu `AudioPlayer.moveAudioSource`.
List<MediaItem> reorderQueue(List<MediaItem> queue, int oldIndex, int newIndex) {
final result = List<MediaItem>.from(queue);
final moved = result.removeAt(oldIndex);
result.insert(newIndex, moved);
return result;
}
/// Der zuletzt als Wiedergabe gezählte Index, nachdem der Titel an
/// [entfernt] aus der Warteschlange genommen wurde.
///
/// Ohne diese Nachführung zeigt der gemerkte Index auf den Nachbarn: der
/// laufende Titel bekäme beim nächsten `currentIndexStream`-Ereignis eine
/// zweite Wiedergabe gutgeschrieben, obwohl er nie neu gestartet wurde.
int? gezaehlterIndexNachEntfernen(int? gezaehlt, int entfernt) {
if (gezaehlt == null) return null;
if (gezaehlt == entfernt) return null;
return gezaehlt > entfernt ? gezaehlt - 1 : gezaehlt;
}
/// Der zuletzt als Wiedergabe gezählte Index, nachdem ein Titel von [von]
/// nach [nach] verschoben wurde. Siehe [gezaehlterIndexNachEntfernen].
int? gezaehlterIndexNachVerschieben(int? gezaehlt, int von, int nach) {
if (gezaehlt == null) return null;
if (gezaehlt == von) return nach;
if (von < gezaehlt && gezaehlt <= nach) return gezaehlt - 1;
if (nach <= gezaehlt && gezaehlt < von) return gezaehlt + 1;
return gezaehlt;
}
/// Navidrome-Song-ID eines Warteschlangen-Eintrags, oder `null` bei einem
/// Titel vom Gerät.
///
/// [MediaItem.id] ist immer schon eine abspielbare URI — `file://` beim Gerät,
/// die fertige Stream-Adresse beim Server — und taugt deshalb NICHT als
/// Server-ID. Früher wurde sie genau dafür benutzt: die App fragte den Server
/// nach einem Titel namens `file:///storage/...`, bekam eine Fehlerantwort und
/// gab die an den Player weiter. Lokale Lieder waren dadurch unabspielbar
/// ("(0) Source error").
String? navidromeIdOf(MediaItem item) => item.extras?['navidromeId'] as String?;
/// UUID des Titels in der lokalen Bibliothek, oder `null` bei einem Titel,
/// der nur am Server liegt. Trägt Historie, Wiedergabezähler und Resume.
String? songIdOf(MediaItem item) => item.extras?['songId'] as String?;
/// Ob der Titel mit der Bibliotheks-UUID [songId] gerade in der Wiedergabe
/// steht. [aktuell] ist der laufende Eintrag, oder `null`, wenn nichts läuft.
///
/// Der leere [songId] gilt nie als laufend: Titel, die nur am Server liegen,
/// haben keine UUID, und ohne diese Prüfung markierte ein Server-Titel jede
/// Zeile, der ebenfalls eine fehlt.
bool istAmLaufen(MediaItem? aktuell, String songId) =>
songId.isNotEmpty && aktuell != null && songIdOf(aktuell) == songId;
/// Ob für [item] im Server-Cache nachgesehen wird. Titel vom Gerät liegen
/// bereits als Datei vor — für sie darf der Server nie befragt werden.
bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
/// Abspielbare Quelle eines Warteschlangen-Eintrags. [cacheTreffer] ist der
/// Pfad einer bereits vollständig zwischengespeicherten Datei, sonst `null`.
Uri quelleFuer(MediaItem item, {String? cacheTreffer}) =>
cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id);
/// Ob [item] ohne Netz läuft: Titel vom Gerät immer, Server-Titel nur mit
/// vollständigem Cache-Treffer.
bool istOfflineVerfuegbar(MediaItem item, {required bool cacheTreffer}) =>
navidromeIdOf(item) == null || cacheTreffer;
/// Der Startindex, nachdem Titel aus der Warteschlange gefiltert wurden.
/// [behalten] hat einen Eintrag je ursprünglichem Titel.
///
/// Fällt der gewählte Titel selbst weg, rückt der nächste behaltene an seine
/// Stelle — der Nutzer landet dort, wo er getippt hat, und nicht am Anfang.
int startIndexNachFilter(List<bool> behalten, int startIndex) {
var davor = 0;
for (var i = 0; i < startIndex && i < behalten.length; i++) {
if (behalten[i]) davor++;
}
final gesamt = behalten.where((b) => b).length;
if (gesamt == 0) return 0;
return davor.clamp(0, gesamt - 1);
}
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
class NichtsOfflineVerfuegbar implements Exception {
const NichtsOfflineVerfuegbar();
@override
String toString() => 'Im Offline-Modus ist keiner dieser Titel verfügbar';
}
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
final MeloDb db;
late final SleepTimer sleepTimer;
late final Timer _positionRecordTimer;
late final CacheManager _cache;
final DownloadSpeicher _downloads = DownloadSpeicher();
/// Cache-Dateien, die gerade als Quelle in der Warteschlange stehen. Sie
/// dürfen nicht verdrängt werden — die Pfade sind im Player schon fest
/// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen.
final Set<String> _benutzteCacheDateien = {};
final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
/// Wird von den App-Einstellungen gesetzt.
bool autoPlayOnHeadphones = false;
/// Alle Titel auf eine ähnliche Lautstärke bringen (ReplayGain).
/// Wird von den App-Einstellungen gesetzt.
bool normalizeVolume = true;
/// Nur abspielen, was ohne Netz da ist. Wird vom Offline-Schalter in den
/// Einstellungen gesetzt; siehe [loadPlaylist].
bool offlineOnly = false;
/// Obergrenze des Zwischenspeichers in Bytes. Wird von den Einstellungen
/// gesetzt; bewusst heruntergeladene Titel liegen woanders und zählen nicht
/// mit (siehe [DownloadSpeicher]).
///
/// **0 heißt „nichts behalten"**, nicht „unbegrenzt" — die Einstellung
/// bietet genau diese Wahl an. Der Startwert entspricht der Voreinstellung
/// der App, damit ein noch nicht übernommener Wert nie versehentlich den
/// ganzen Zwischenspeicher leert.
int cacheGrenzeBytes = 2048 * 1024 * 1024;
MeloAudioHandler({required this.db}) {
_cache = CacheManager();
_cache.init();
// Einmalig beim Start — nicht bei jeder Wiedergabe, sonst verzögert ein
// Speicherzugriff jeden Titelstart. Gebraucht nur für Server-Titel.
unawaited(_nav.ladeGespeicherteZugangsdaten());
sleepTimer = SleepTimer(onElapsed: pause);
// just_audio-Events → audio_service PlaybackState
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
// Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird.
// Synche auch zum Server wenn verbunden.
_positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) {
final item = mediaItem.value;
if (!_player.playing || item == null) return;
final posMs = _player.position.inMilliseconds;
final songId = songIdOf(item);
if (songId != null) db.recordPlayback(songId, posMs);
// Nur echte Server-Titel scrobbeln — eine lokale UUID kennt der
// Server nicht und jeder Aufruf wäre eine Anfrage ins Leere.
final navId = navidromeIdOf(item);
if (navId != null && _nav.istVerbunden) {
_nav.scrobble(navId, posMs ~/ 1000);
}
});
// Aktuellen Track ans System melden (Lockscreen/Notification).
// Bereits bekannte Dauer erhalten, damit sie nicht auf null zurückfällt.
_player.currentIndexStream.listen((index) {
final q = queue.value;
if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration));
_applyGain(q[index]);
// Jetzt läuft er wirklich — erst das macht ihn im Sinne der
// Verdrängung zu einem „zuletzt gehörten" Titel.
final navId = navidromeIdOf(q[index]);
if (navId != null) {
unawaited(_cache.beruehre(NavidromeService.cacheSchluessel(navId)));
}
if (shouldCountPlay(index, _lastCountedIndex)) {
_lastCountedIndex = index;
final songId = songIdOf(q[index]);
if (songId != null) db.incrementPlayCount(songId);
}
}
});
_watchHeadphones();
// Ermittelte Dauer nachtragen
_player.durationStream.listen((duration) {
final item = mediaItem.value;
if (item != null && duration != null) {
mediaItem.add(item.copyWith(duration: duration));
}
});
}
/// Gleicht die Lautstärke des Titels an. Ohne ReplayGain-Tag oder bei
/// abgeschalteter Einstellung bleibt sie unverändert.
void _applyGain(MediaItem item) {
final gain = item.extras?['gainDb'] as double?;
_player.setVolume(normalizeVolume ? volumeForGain(gain) : 1.0);
}
/// Startet die Wiedergabe, wenn Kopfhörer verbunden werden und der Nutzer
/// das in den Einstellungen erlaubt hat.
Future<void> _watchHeadphones() async {
final session = await AudioSession.instance;
session.devicesChangedEventStream.listen((event) {
final added = event.devicesAdded.map((d) => d.type).toSet();
if (shouldAutoPlay(
added: added,
enabled: autoPlayOnHeadphones,
hasQueue: queue.value.isNotEmpty,
playing: _player.playing,
)) {
play();
}
});
}
/// Audio-Session des Players — die Klangeffekte hängen daran.
Stream<int?> get androidAudioSessionIdStream =>
_player.androidAudioSessionIdStream;
/// Ersetzt die Warteschlange und startet ab [startIndex]. Gibt zurück, wie
/// viele Titel der Offline-Modus übersprungen hat (sonst 0).
///
/// Baut die Quellen ohne einen einzigen Netzzugriff: [MediaItem.id] ist
/// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange
/// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln
/// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den
/// laufenden Ladevorgang ab ("Loading interrupted").
///
/// Ist [offlineOnly] gesetzt, fallen Server-Titel ohne Cache-Treffer heraus,
/// statt still gestreamt zu werden. Wirft [NichtsOfflineVerfuegbar], wenn
/// dadurch nichts übrig bleibt.
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
// Ohne Titel gibt es nichts zu tun. Ohne diese Zeile sähe der Filter
// unten "alle übersprungen" und meldete fälschlich den Offline-Modus.
if (items.isEmpty) return 0;
// Die alte Warteschlange ist gleich weg; ihre Dateien dürfen wieder
// verdrängt werden.
_benutzteCacheDateien.clear();
final quellen = [for (final item in items) await _quelle(item)];
final behalten = [for (final quelle in quellen) quelle != null];
final uebersprungen = behalten.where((b) => !b).length;
if (uebersprungen == items.length) throw const NichtsOfflineVerfuegbar();
final gefiltert = <MediaItem>[];
final sources = <AudioSource>[];
for (var i = 0; i < items.length; i++) {
final quelle = quellen[i];
if (quelle == null) continue;
gefiltert.add(items[i]);
sources.add(quelle);
}
final start = startIndexNachFilter(behalten, startIndex);
queue.add(gefiltert);
// Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
// denselben Index wie der zuletzt gezählte hat.
_lastCountedIndex = null;
await _player.setAudioSources(sources, initialIndex: start);
// Bei bekannter letzter Position an dieser Stelle fortsetzen,
// statt immer von vorne zu beginnen.
final item = gefiltert[start];
final resumeMs = await _fortsetzPosition(item);
if (resumeMs != null) {
await _player.seek(Duration(milliseconds: resumeMs), index: start);
}
await play();
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
final navId = navidromeIdOf(item);
// Wer den Titel bewusst heruntergeladen hat, braucht keine zweite Kopie
// im Zwischenspeicher — das wäre doppelter Platz und doppeltes Datenvolumen.
if (navId != null && await _downloads.treffer(navId) == null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache).then((_) {
// Direkt nach dem Ablegen aufräumen — sonst wächst der
// Zwischenspeicher weiter unbegrenzt.
raeumeZwischenspeicherAuf();
}));
}
return uebersprungen;
}
/// Baut die Abspiel-Quelle für [item] und sieht dabei einmal im Cache nach:
/// ein vollständig zwischengespeicherter Server-Titel läuft ohne Netz.
///
/// Gibt `null` zurück, wenn [offlineOnly] gesetzt ist und der Titel das Netz
/// bräuchte. Eine Stelle für alle drei Wege in die Warteschlange — sonst
/// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen.
Future<AudioSource?> _quelle(MediaItem item) async {
final navId = navidromeIdOf(item);
// Erst die bewussten Downloads, dann der Zwischenspeicher: was der Nutzer
// mitgenommen hat, soll nie am Netz hängen — auch nicht, wenn zufällig
// eine Kopie im Cache liegt, die morgen verdrängt sein kann.
final treffer = navId == null
? null
: await _downloads.treffer(navId) ??
await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
if (offlineOnly &&
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
return null;
}
if (treffer != null) _benutzteCacheDateien.add(treffer.path);
return AudioSource.uri(
quelleFuer(item, cacheTreffer: treffer?.path),
tag: item,
);
}
/// Hält den Zwischenspeicher unter der eingestellten Grenze, ohne dabei
/// Dateien der laufenden Warteschlange anzufassen.
Future<void> raeumeZwischenspeicherAuf() =>
_cache.begrenzeAuf(cacheGrenzeBytes, geschuetzt: _benutzteCacheDateien);
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
/// allein die lokale Historie.
Future<int?> _fortsetzPosition(MediaItem item) async {
final navId = navidromeIdOf(item);
if (navId != null && _nav.istVerbunden) {
final serverMs = await _nav.getBookmark(navId);
if (serverMs != null && shouldResumeAt(serverMs, item.duration)) {
return serverMs;
}
}
final songId = songIdOf(item);
if (songId != null) {
final localMs = await db.lastPosition(songId);
if (localMs != null && shouldResumeAt(localMs, item.duration)) {
return localMs;
}
}
return null;
}
/// Hängt [item] hinten an die Warteschlange an, ohne die laufende
/// Wiedergabe zu unterbrechen. Favoriten und Wiedergabelisten bleiben
/// unberührt — die Warteschlange ist nur für diese Sitzung.
///
/// `false`, wenn der Offline-Modus den Titel ausschließt.
Future<bool> addToQueue(MediaItem item) async {
final quelle = await _quelle(item);
if (quelle == null) return false;
await _player.addAudioSource(quelle);
queue.add([...queue.value, item]);
return true;
}
/// Spielt [item] als Nächstes, direkt nach dem laufenden Titel.
/// `false`, wenn der Offline-Modus den Titel ausschließt.
///
/// Eingefügt wird immer *hinter* dem laufenden Titel — der zuletzt gezählte
/// Index verschiebt sich dadurch nie.
Future<bool> playNext(MediaItem item) async {
final quelle = await _quelle(item);
if (quelle == null) return false;
final index = playNextIndex(
currentIndex: _player.currentIndex,
queueLength: queue.value.length,
);
await _player.insertAudioSource(index, quelle);
final updated = [...queue.value]..insert(index, item);
queue.add(updated);
return true;
}
/// Nimmt den Titel an [index] aus der Warteschlange, ohne die Wiedergabe
/// zu unterbrechen.
///
/// Früher baute diese Aktion die ganze Warteschlange mit [loadPlaylist] neu
/// auf: die Wiedergabe setzte hörbar aus, sprang an den Anfang des Titels
/// zurück und wurde erneut gezählt. `just_audio` kann einzelne Quellen
/// entfernen — der laufende Titel merkt davon nichts.
/// Warteschlange und Zählerstand werden **vor** dem Player gesetzt.
///
/// `just_audio` sendet beim Umbauen seiner Quellenliste noch während des
/// `await` neue Index-Ereignisse. Käme unsere Buchführung erst danach, sähe
/// der Zuhörer auf `currentIndexStream` (siehe Konstruktor) die *alte*
/// Warteschlange und meldete den falschen Titel — samt Wiedergabe-Zählung
/// für ein Lied, das gar nicht läuft. [loadPlaylist] macht es genauso.
@override
Future<void> removeQueueItemAt(int index) async {
final aktuell = queue.value;
if (index < 0 || index >= aktuell.length) return;
final rest = removeFromQueue(aktuell, index);
queue.add(rest);
_lastCountedIndex = gezaehlterIndexNachEntfernen(_lastCountedIndex, index);
await _player.removeAudioSourceAt(index);
// War es der letzte Titel, bleibt sonst eine Benachrichtigung ohne
// Wiedergabe stehen — mitsamt Knöpfen, die ins Leere greifen.
if (rest.isEmpty) await stop();
}
/// Verschiebt den Titel von [oldIndex] nach [newIndex], ohne die Wiedergabe
/// zu unterbrechen. Siehe [removeQueueItemAt] — auch hier steht die
/// Buchführung vor dem Player.
Future<void> moveQueueItem(int oldIndex, int newIndex) async {
final aktuell = queue.value;
if (oldIndex < 0 || oldIndex >= aktuell.length) return;
if (newIndex < 0 || newIndex >= aktuell.length) return;
if (oldIndex == newIndex) return;
queue.add(reorderQueue(aktuell, oldIndex, newIndex));
_lastCountedIndex =
gezaehlterIndexNachVerschieben(_lastCountedIndex, oldIndex, newIndex);
await _player.moveAudioSource(oldIndex, newIndex);
}
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
/// Der Positions-Timer wird hier bewusst **nicht** abgebrochen.
///
/// Er ist ein `Timer.periodic` und entsteht nur einmal im Konstruktor —
/// einmal abgebrochen, käme er für den Rest der App-Sitzung nicht zurück.
/// [stop] löst aber nicht nur der Nutzer aus: das System ruft es beim
/// Wegwischen der Benachrichtigung auf. Danach merkte sich die App keine
/// Hörposition mehr und scrobbelte nicht mehr — bis zum Neustart und ohne
/// jeden Hinweis. Er kostet nichts: seine erste Zeile steigt aus, solange
/// nichts läuft. Abgebrochen wird er allein in [dispose].
@override
Future<void> stop() async {
sleepTimer.cancel();
await _player.stop();
await super.stop();
}
/// Gibt Hintergrund-Ressourcen frei (Positions-Timer), ohne die Wiedergabe
/// zu stoppen. Für Tests, die den Handler ohne vollständigen App-Lifecycle
/// verwenden und daher [stop] nicht durchlaufen.
void dispose() {
_positionRecordTimer.cancel();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
@override
Future<void> skipToQueueItem(int index) => _player.seek(Duration.zero, index: index);
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
final enabled = shuffleMode == AudioServiceShuffleMode.all;
if (enabled) await _player.shuffle();
await _player.setShuffleModeEnabled(enabled);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) {
return _player.setLoopMode(switch (repeatMode) {
AudioServiceRepeatMode.one => LoopMode.one,
AudioServiceRepeatMode.all => LoopMode.all,
_ => LoopMode.off,
});
}
PlaybackState _transformEvent(PlaybackEvent event) {
return PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (_player.playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
},
androidCompactActionIndices: const [0, 1, 2],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState] ??
AudioProcessingState.idle,
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: event.currentIndex,
);
}
// Streams, die die UI beobachtet. Dauer direkt vom Player — zuverlässiger
// als mediaItem.duration, das beim Track-Wechsel kurzzeitig null sein kann.
Stream<Duration> get positionStream => _player.positionStream;
Stream<Duration?> get durationStream => _player.durationStream;
}