feat(player): surface playback errors via debounced SnackBar (#58)

_handlePlaybackError silently skipped a dead track (404 / decoder /
EOS / network drop) with only a debugPrint, hiding the signal that
tells a broken track from a flaky app.

- audio_handler: _playbackErrors broadcast stream; emit the failing
  track title (mediaItem.value?.title — correctly mapped post-#49)
  before the skip/pause
- playback_error_reporter (new): global scaffoldMessengerKey +
  reporter that buffers, 2s-debounces, and coalesces bursts into one
  SnackBar ("Couldn't play X — skipping" / "Skipped N unplayable
  tracks")
- app.dart: scaffoldMessengerKey on MaterialApp.router + postFrame read

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 19:39:58 -04:00
parent c659165218
commit 25ee54fca0
3 changed files with 98 additions and 0 deletions
@@ -151,6 +151,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
Stream<Duration> get positionStream => _player.positionStream;
Duration get position => _player.position;
/// Broadcasts the title of a track that just failed to play (404,
/// decoder failure, premature EOS, network drop) and was auto-skipped
/// or paused by _handlePlaybackError. The app listens and surfaces a
/// debounced/coalesced SnackBar so a silent skip isn't mysterious.
/// App-lifetime singleton handler — intentionally never closed.
final _playbackErrors = StreamController<String>.broadcast();
Stream<String> get playbackErrorStream => _playbackErrors.stream;
void configure({
required String baseUrl,
required String? token,
@@ -478,6 +486,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// If we're at the last track, seekToNext is a no-op; the state
/// drops to idle and _broadcastState reflects that.
Future<void> _handlePlaybackError() async {
// mediaItem is the correctly-mapped current track (post #49 logical
// index), so it's the one that just failed — no player-index math.
final failed = mediaItem.value?.title;
if (failed != null && failed.isNotEmpty) {
_playbackErrors.add(failed);
}
final currentIdx = _player.currentIndex;
final queueLen = queue.value.length;
if (currentIdx == null || currentIdx + 1 >= queueLen) {