Bisher scrollte man durch die Bibliothek und musste raten, wo man ist — die einzige Rueckmeldung war die Leiste ganz unten. Drei tanzende Balken ueber dem Coverbild des laufenden Titels, sein Name in Rot. Die Balken liegen auf dem Cover statt daneben: eine vierte Spalte haette dem Titel Platz genommen und die Zeile bei jedem Titelwechsel in der Breite springen lassen. Pausiert bleibt die Markierung stehen, die Balken stehen still. Bei abgeschalteten Systemanimationen ebenfalls — eine Endlosschleife ist genau das, was diese Einstellung vermeiden soll. Die Markierung sitzt in SongZeile und gilt damit in einem Rutsch fuer Meine Musik, Favoriten, Suche, Kuenstler, Kategorie und "Zuletzt hinzugefuegt". Warteschlange und Wiedergabeliste bauen ihre Zeilen selbst (dort steht rechts Entfernen statt Herz und Menue) und haben dasselbe Zeichen einzeln bekommen. Nur die laufende Zeile hoert auf den Wiedergabe-Zustand: der meldet auch den Pufferstand und feuert mehrmals pro Sekunde. Haenge jede sichtbare Zeile daran, baute sich die halbe Liste staendig neu auf. Aus dem Code-Review nachgebessert: Startwert fuer den Strom, sonst erschien die Markierung beim Zurueckscrollen einen Frame zu spaet; die Zeile aendert beim Wechsel nicht mehr die Form ihres Aufbaus, sonst verlor das Herz daneben sein Abo und sah kurz leer aus; die Wiedergabeliste bekam die Markierung ueberhaupt erst (der Changelog hatte sie faelschlich schon behauptet); der Abdunkel-Schleier nimmt den Eckenradius jetzt entgegen statt ihn fest zu verdrahten. Testluecke geschlossen, die der Review benannt hat: geprueft waren nur die Regel und die Balken je fuer sich, die Verdrahtung dazwischen nicht — genau dort sassen alle drei Fehler. 411 Tests gruen (vorher 396), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
502 lines
20 KiB
Dart
502 lines
20 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/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 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;
|
|
|
|
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]);
|
|
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;
|
|
|
|
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);
|
|
if (navId != null) {
|
|
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
|
|
}
|
|
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);
|
|
final treffer = navId == null
|
|
? null
|
|
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
|
|
if (offlineOnly &&
|
|
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
|
|
return null;
|
|
}
|
|
return AudioSource.uri(
|
|
quelleFuer(item, cacheTreffer: treffer?.path),
|
|
tag: item,
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|