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:
@@ -9,6 +9,7 @@ import 'cache/prefetcher.dart';
|
|||||||
import 'cache/resume_controller.dart';
|
import 'cache/resume_controller.dart';
|
||||||
import 'cache/sync_controller.dart';
|
import 'cache/sync_controller.dart';
|
||||||
import 'player/play_events_reporter.dart';
|
import 'player/play_events_reporter.dart';
|
||||||
|
import 'player/playback_error_reporter.dart';
|
||||||
import 'shared/live_events_dispatcher.dart';
|
import 'shared/live_events_dispatcher.dart';
|
||||||
import 'shared/routing.dart';
|
import 'shared/routing.dart';
|
||||||
import 'theme/theme_data.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
|
// pause, and app teardown. Pairs with the #52 idle teardown so a
|
||||||
// torn-down session is recoverable instead of lost.
|
// torn-down session is recoverable instead of lost.
|
||||||
ref.read(resumeControllerProvider);
|
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
|
// Offline marker (#427 S1): periodic /healthz reachability
|
||||||
// probe → offlineProvider. Read here to start the poller; S4
|
// probe → offlineProvider. Read here to start the poller; S4
|
||||||
// gates system-playlist play + Shuffle-all on it.
|
// 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;
|
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Minstrel',
|
title: 'Minstrel',
|
||||||
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
theme: buildLightTheme(),
|
theme: buildLightTheme(),
|
||||||
darkTheme: buildDarkTheme(),
|
darkTheme: buildDarkTheme(),
|
||||||
themeMode: mode.materialMode,
|
themeMode: mode.materialMode,
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
Stream<Duration> get positionStream => _player.positionStream;
|
Stream<Duration> get positionStream => _player.positionStream;
|
||||||
Duration get position => _player.position;
|
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({
|
void configure({
|
||||||
required String baseUrl,
|
required String baseUrl,
|
||||||
required String? token,
|
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
|
/// If we're at the last track, seekToNext is a no-op; the state
|
||||||
/// drops to idle and _broadcastState reflects that.
|
/// drops to idle and _broadcastState reflects that.
|
||||||
Future<void> _handlePlaybackError() async {
|
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 currentIdx = _player.currentIndex;
|
||||||
final queueLen = queue.value.length;
|
final queueLen = queue.value.length;
|
||||||
if (currentIdx == null || currentIdx + 1 >= queueLen) {
|
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
|
||||||
|
? '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<PlaybackErrorReporter>((ref) {
|
||||||
|
final r = PlaybackErrorReporter(ref);
|
||||||
|
ref.onDispose(r.dispose);
|
||||||
|
r.start();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user