import 'dart:async'; import 'package:flutter/material.dart'; import '../tema/pluriwave_tokens.dart'; /// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design /// "Timeout without re-init"): the `audio_service` plugin's self-bind has no /// native timeout and an unhandled `onConnectionSuspended` case, so under /// bind contention (Android Auto cold start) the handshake can hang forever. /// Top-level const so tests can reference the production value without /// duplicating it. /// /// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the /// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no /// `dependency_overrides`. Anyone reading the sentence above would go looking /// for a local copy to patch, and there is none — the behaviour described is /// upstream's, so the workaround has to live here. const timeoutArranqueAudio = Duration(seconds: 8); /// Outcome of racing an `AudioService.init` future against /// [timeoutArranqueAudio] (Design "Timeout without re-init"). Sealed so /// callers exhaustively handle both branches. sealed class ResultadoArranqueAudio { const ResultadoArranqueAudio(); } /// The handler future completed within the timeout — normal startup path. class ArranqueAudioListo extends ResultadoArranqueAudio { const ArranqueAudioListo(this.handler); final T handler; } /// The handler future did NOT complete within the timeout. [handlerFuturo] /// is the SAME original future passed to [esperarArranqueAudio] — the /// caller must keep awaiting it (e.g. via [ArranqueAudioApp]), never start a /// second `AudioService.init` call (Design "init must never be called /// twice"). class ArranqueAudioPendiente extends ResultadoArranqueAudio { const ArranqueAudioPendiente(this.handlerFuturo); final Future handlerFuturo; } /// Races the already-started [handlerFuturo] — a single `AudioService.init` /// call — against [timeout] (Design "Timeout without re-init"). Returns /// [ArranqueAudioListo] when [handlerFuturo] resolves in time, otherwise /// [ArranqueAudioPendiente] wrapping the SAME [handlerFuturo] so it can keep /// being awaited without ever re-invoking `AudioService.init`. /// /// [handlerFuturo] and [timeout] are both injected — this function never /// touches the real `audio_service` plugin, so it is testable with a plain /// [Future] and short durations. Future> esperarArranqueAudio( Future handlerFuturo, { Duration timeout = timeoutArranqueAudio, }) async { try { final handler = await handlerFuturo.timeout(timeout); return ArranqueAudioListo(handler); } on TimeoutException { return ArranqueAudioPendiente(handlerFuturo); } } /// Subscribes to [errores] — in production `AudioService.asyncError` — and /// hands every event to [registrar]. Returns the [StreamSubscription] so the /// caller can cancel it when the handler is torn down. /// /// Why this exists: `audio_service` funnels EVERY asynchronous failure of its /// own observers into that stream and nothing else /// (`_observePlaybackState`/`_observeMediaItem`/`_observeQueue` each wrap /// their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork /// path uses `.catchError(_asyncError.add)`), yet this app had ZERO /// subscribers on it. A `PublishSubject` with no listeners simply drops /// events, so the platform-side exception behind "the media notification /// disappeared" — a rejected `setState`, a failed `setMediaItem`, an /// Android 12+ `ForegroundServiceStartNotAllowedException` surfacing through /// the plugin — was being discarded without a single log line. This makes /// that channel audible. /// /// [errores] and [registrar] are both injected — this function never touches /// the real `audio_service` plugin, so it is testable with a plain /// [StreamController] (same seam convention as [esperarArranqueAudio] above, /// and as `decidirAvanceCola`/`debeReaplicarEcualizador` elsewhere). StreamSubscription observarErroresAudio( Stream errores, { required void Function(Object error) registrar, }) { return errores.listen( registrar, // The plugin only ever feeds this subject through `add`, never // `addError`, so this branch is purely defensive: a stream-level error // would otherwise escape as an unhandled zone error, which is strictly // worse than one more log line. onError: (Object error, StackTrace _) => registrar(error), cancelOnError: false, ); } /// Default [observarErroresAudio] logger: one line per swallowed plugin /// exception. /// /// Uses [debugPrint], NOT `dart:developer`'s `log`. That distinction is the /// whole reason this channel existed for weeks without ever producing a /// single line of evidence: `log()` writes to the VM service, which a /// RELEASE build does not have, so every exception this was built to catch /// was still being thrown away — just one layer further down than before. /// `debugPrint` reaches logcat in release, which is the only build that ever /// runs in the car. void registrarErrorAudioService(Object error) { debugPrint('[PluriWave][ArranqueAudio] AudioService.asyncError: $error'); } /// Minimal branded bootstrap widget for the degraded path (Design "still /// call runApp, but with a minimal bootstrap widget that keeps waiting on /// the SAME original future"). Shows [_CargandoArranqueAudio] while /// [handlerFuturo] is pending; once it settles, swaps to [construirApp]'s /// result. On success, [alListo] is called exactly once (handler wiring: /// `registrarHandler` + `ServicioAudioSession`) before the swap. On an /// error, [alListo] is never called — the app shell still renders (an /// infinite spinner is strictly worse) and the error is reported via /// `FlutterError.reportError` instead of becoming an unhandled exception. class ArranqueAudioApp extends StatefulWidget { const ArranqueAudioApp({ super.key, required this.handlerFuturo, required this.alListo, required this.construirApp, }); /// The SAME future returned by the original `AudioService.init` call — /// never a new one. final Future handlerFuturo; /// Handler-wiring callback (Design "same handler wiring" as the /// on-time path). Invoked exactly once, when [handlerFuturo] resolves. final void Function(T handler) alListo; /// Builds the real app widget once [handlerFuturo] has settled — with the /// resolved handler on success, or `null` if [handlerFuturo] completed /// with an error (Design "error path": an infinite spinner is strictly /// worse than an app shell without the handler wired). final Widget Function(T? handler) construirApp; @override State> createState() => _ArranqueAudioAppState(); } class _ArranqueAudioAppState extends State> { @override void initState() { super.initState(); // Attached once in initState (not in build) so alListo runs exactly // once regardless of how many times FutureBuilder rebuilds below. unawaited( widget.handlerFuturo.then( widget.alListo, // Design "error path": a post-timeout handler failure must never // become an unhandled async exception. alListo is deliberately NOT // called here — it only wires a real handler; build() below still // swaps away from the spinner via `connectionState == done` // regardless of hasError, so the app shell renders either way. onError: (Object error, StackTrace stackTrace) { FlutterError.reportError( FlutterErrorDetails( exception: error, stack: stackTrace, library: 'arranque_audio', context: ErrorDescription( 'esperando handlerFuturo tras el timeout de arranque de audio', ), ), ); }, ), ); } @override Widget build(BuildContext context) { return FutureBuilder( future: widget.handlerFuturo, builder: (context, snapshot) { // `done` covers BOTH hasData and hasError — an errored handshake // still swaps to the app shell instead of spinning forever (Design // "error path"). if (snapshot.connectionState == ConnectionState.done) { return widget.construirApp(snapshot.data); } return const _CargandoArranqueAudio(); }, ); } } /// Standalone loading screen shown while [ArranqueAudioApp] waits (Design /// "minimal branded loading view"): centered `CircularProgressIndicator`, /// no text — `AppLocalizations` is not available at this point in startup. /// Self-contained (its own `MaterialApp`) since this can be the direct /// `runApp()` root. class _CargandoArranqueAudio extends StatelessWidget { const _CargandoArranqueAudio(); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( backgroundColor: PluriWaveTokens.dark.deepViolet, body: const Center( child: CircularProgressIndicator(color: PluriWaveTokens.brand), ), ), ); } }