25ee54fca0
_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>
79 lines
2.4 KiB
Dart
79 lines
2.4 KiB
Dart
// 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;
|
||
});
|