// 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; });