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 vendored `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. 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); } } /// 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), ), ), ); } }