From 25ee54fca04fe58bb05e269ad82b5c1fb62e2143 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 18 May 2026 19:39:58 -0400 Subject: [PATCH] feat(player): surface playback errors via debounced SnackBar (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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) --- flutter_client/lib/app.dart | 6 ++ flutter_client/lib/player/audio_handler.dart | 14 ++++ .../lib/player/playback_error_reporter.dart | 78 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 flutter_client/lib/player/playback_error_reporter.dart diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index a963d718..2c9a5160 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -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 { // 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 { final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system; return MaterialApp.router( title: 'Minstrel', + scaffoldMessengerKey: scaffoldMessengerKey, theme: buildLightTheme(), darkTheme: buildDarkTheme(), themeMode: mode.materialMode, diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 42e8dcfe..540c061a 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -151,6 +151,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl Stream 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.broadcast(); + Stream 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 _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) { diff --git a/flutter_client/lib/player/playback_error_reporter.dart b/flutter_client/lib/player/playback_error_reporter.dart new file mode 100644 index 00000000..b4f8b67d --- /dev/null +++ b/flutter_client/lib/player/playback_error_reporter.dart @@ -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(); + +class PlaybackErrorReporter { + PlaybackErrorReporter(this._ref); + final Ref _ref; + + StreamSubscription? _sub; + Timer? _debounce; + final _buffer = []; + 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 + ? 'Couldn’t 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((ref) { + final r = PlaybackErrorReporter(ref); + ref.onDispose(r.dispose); + r.start(); + return r; +});