Files
minstrel/flutter_client/lib/player/playback_error_reporter.dart
T
bvandeusen 25ee54fca0 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>
2026-05-18 19:39:58 -04:00

79 lines
2.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
});