From edc93224b021f0aa6858acf1f61a5fb701b99fe3 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 18 Aug 2026 18:17:26 +0200 Subject: [PATCH] feat(player): Wiedergabeposition merken und beim Laden fortsetzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeloAudioHandler bekommt MeloDb per Konstruktor injiziert - Positions-Timer (alle 5s) schreibt db.recordPlayback(songId, positionMs) während aktiv abgespielt wird (songId aus mediaItem.extras['songId']) - loadPlaylist liest db.lastPosition() für den Start-Song und seekt vor dem play() an die gespeicherte Position, statt immer von vorne zu starten - shouldResumeAt() als reine, testbare Funktion extrahiert: kein Resume bei Position 0 oder innerhalb der letzten 3s einer bekannten Track-Dauer - dispose() zum gefahrlosen Abbrechen des Positions-Timers in Tests ohne vollen App-Lifecycle (super.stop() lässt sich in Widget-Tests wegen der aktiven playbackState-Pipe nicht gefahrlos aufrufen) - library_screen_test.dart: MeloAudioHandler-Konstruktion angepasst (db jetzt Pflichtparameter) + handler.dispose() im Teardown Co-Authored-By: Claude Haiku 4.5 --- lib/main.dart | 2 +- lib/player/audio_handler.dart | 47 ++++++++++++++++++++++++++- test/library/library_screen_test.dart | 9 +++-- test/player/resume_test.dart | 26 +++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 test/player/resume_test.dart diff --git a/lib/main.dart b/lib/main.dart index 6bf193d..6ce6f39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,7 @@ Future main() async { _library = LibraryService(_db); _playlists = PlaylistService(_db); _handler = await AudioService.init( - builder: () => MeloAudioHandler(), + builder: () => MeloAudioHandler(db: _db), config: const AudioServiceConfig( androidNotificationChannelId: 'de.baka.melo.audio', androidNotificationChannelName: 'Melo', diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 040272b..eb39395 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -1,18 +1,44 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; +import '../library/database.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; +} + /// 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; - MeloAudioHandler() { + MeloAudioHandler({required this.db}) { sleepTimer = SleepTimer(onElapsed: pause); // just_audio-Events → audio_service PlaybackState _player.playbackEventStream.map(_transformEvent).pipe(playbackState); + // Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird. + _positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) { + final songId = mediaItem.value?.extras?['songId'] as String?; + if (_player.playing && songId != null) { + db.recordPlayback(songId, _player.position.inMilliseconds); + } + }); + // Aktuellen Track ans System melden (Lockscreen/Notification). // Bereits bekannte Dauer erhalten, damit sie nicht auf null zurückfällt. _player.currentIndexStream.listen((index) { @@ -38,6 +64,17 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { .map((item) => AudioSource.uri(Uri.parse(item.id), tag: item)) .toList(); await _player.setAudioSources(sources, initialIndex: startIndex); + + // Bei bekannter letzter Position an dieser Stelle fortsetzen, + // statt immer von vorne zu beginnen. + final songId = items[startIndex].extras?['songId'] as String?; + if (songId != null) { + final lastMs = await db.lastPosition(songId); + if (lastMs != null && shouldResumeAt(lastMs, items[startIndex].duration)) { + await _player.seek(Duration(milliseconds: lastMs), index: startIndex); + } + } + await play(); } @@ -50,10 +87,18 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { @override Future stop() async { sleepTimer.cancel(); + _positionRecordTimer.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 seek(Duration position) => _player.seek(position); diff --git a/test/library/library_screen_test.dart b/test/library/library_screen_test.dart index 3f19263..a3b3aaa 100644 --- a/test/library/library_screen_test.dart +++ b/test/library/library_screen_test.dart @@ -51,7 +51,7 @@ void main() { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); final playlists = PlaylistService(db); - final handler = MeloAudioHandler(); + final handler = MeloAudioHandler(db: db); await tester.pumpWidget( _wrap(db, lib, playlists, handler, const LibraryScreen()), @@ -62,6 +62,7 @@ void main() { expect(find.text('Künstler'), findsOneWidget); expect(find.text('Alben'), findsOneWidget); + handler.dispose(); await db.close(); }); @@ -69,7 +70,7 @@ void main() { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); final playlists = PlaylistService(db); - final handler = MeloAudioHandler(); + final handler = MeloAudioHandler(db: db); await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice'); await _insertSong(db, id: '2', title: 'Song B', artist: 'Bob'); @@ -85,6 +86,7 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect(find.text('1 Songs'), findsNWidgets(2)); + handler.dispose(); await db.close(); }); @@ -92,7 +94,7 @@ void main() { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); final playlists = PlaylistService(db); - final handler = MeloAudioHandler(); + final handler = MeloAudioHandler(db: db); await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice', album: 'Best Of'); await tester.pumpWidget( @@ -105,6 +107,7 @@ void main() { expect(find.text('Best Of'), findsOneWidget); + handler.dispose(); await db.close(); }); } diff --git a/test/player/resume_test.dart b/test/player/resume_test.dart new file mode 100644 index 0000000..390760e --- /dev/null +++ b/test/player/resume_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/audio_handler.dart'; + +void main() { + group('shouldResumeAt', () { + test('kein Resume bei Position 0', () { + expect(shouldResumeAt(0, const Duration(minutes: 3)), false); + }); + + test('kein Resume innerhalb der letzten 3 Sekunden einer bekannten Dauer', () { + final duration = const Duration(minutes: 3); + final lastPositionMs = duration.inMilliseconds - 2000; // 2s vor Ende + expect(shouldResumeAt(lastPositionMs, duration), false); + }); + + test('Resume bei Position deutlich vor dem Ende', () { + final duration = const Duration(minutes: 3); + final lastPositionMs = duration.inMilliseconds - 60000; // 1min vor Ende + expect(shouldResumeAt(lastPositionMs, duration), true); + }); + + test('Resume bei unbekannter Dauer (null) und Position > 0', () { + expect(shouldResumeAt(45000, null), true); + }); + }); +}