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
+6
View File
@@ -9,6 +9,7 @@ import 'cache/prefetcher.dart';
import 'cache/resume_controller.dart';
import 'cache/sync_controller.dart';
import 'player/play_events_reporter.dart';
import 'player/playback_error_reporter.dart';
import 'shared/live_events_dispatcher.dart';
import 'shared/routing.dart';
import 'theme/theme_data.dart';
@@ -68,6 +69,10 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
// pause, and app teardown. Pairs with the #52 idle teardown so a
// torn-down session is recoverable instead of lost.
ref.read(resumeControllerProvider);
// Playback-error reporter (#58): turns the handler's silent
// dead-track skips into a debounced/coalesced SnackBar so a
// vanished track isn't mysterious (and aids server/cache debug).
ref.read(playbackErrorReporterProvider);
// Offline marker (#427 S1): periodic /healthz reachability
// probe → offlineProvider. Read here to start the poller; S4
// gates system-playlist play + Shuffle-all on it.
@@ -81,6 +86,7 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
return MaterialApp.router(
title: 'Minstrel',
scaffoldMessengerKey: scaffoldMessengerKey,
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: mode.materialMode,
@@ -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) {
@@ -0,0 +1,78 @@
// Surfaces playback errors (#58). _handlePlaybackError in the audio
// handler silently skips a dead track (404 / decoder failure / premature
// EOS / network drop) with only a debugPrint — which hides exactly the
// signal that distinguishes "this track is broken" from "the app is
// flaky" (server file moved, auth expired, cache miss, transcode fail).
//
// This listens to the handler's playbackErrorStream and shows a
// transient SnackBar via a global ScaffoldMessenger key. Bursts are
// coalesced: a debounce window collects errors and emits one message
// ("Skipped N unplayable tracks") instead of stacking N toasts.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'player_provider.dart';
/// Set on MaterialApp.router so SnackBars can be shown from outside any
/// widget's BuildContext (the handler's error stream is isolate-side).
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class PlaybackErrorReporter {
PlaybackErrorReporter(this._ref);
final Ref _ref;
StreamSubscription<String>? _sub;
Timer? _debounce;
final _buffer = <String>[];
bool _disposed = false;
void start() {
try {
// audioHandlerProvider throws until main() overrides it (the real
// app always does). In tests / no-audio environments there's
// nothing to report — stay inert.
final h = _ref.read(audioHandlerProvider);
_sub = h.playbackErrorStream.listen(_onError);
} catch (_) {
return;
}
}
void _onError(String title) {
if (_disposed) return;
_buffer.add(title);
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 2), _flush);
}
void _flush() {
if (_disposed || _buffer.isEmpty) return;
final n = _buffer.length;
final first = _buffer.first;
_buffer.clear();
final msg = n == 1
? 'Couldnt play “$first” — skipping'
: 'Skipped $n unplayable tracks';
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(content: Text(msg)),
);
}
void dispose() {
_disposed = true;
_debounce?.cancel();
_sub?.cancel();
}
}
/// Read once at app start (app.dart postFrame). Disposed via
/// ref.onDispose when the scope tears down.
final playbackErrorReporterProvider = Provider<PlaybackErrorReporter>((ref) {
final r = PlaybackErrorReporter(ref);
ref.onDispose(r.dispose);
r.start();
return r;
});