import 'dart:async'; import 'dart:developer' as developer; import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'servicios/arranque_audio.dart'; import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; import 'servicios/servicio_presets_personalizados.dart'; import 'tema/pluriwave_tokens.dart'; const _anchoMinimoLandscape = 600.0; /// Branded monochrome status-bar icon, replacing the default full-color /// launcher silhouette fallback. const androidNotificationIconResource = 'drawable/ic_stat_pluriwave'; /// S5-R8: media notification accent uses the brand color, not the M3 /// default purple. Top-level const so tests can assert it. const configuracionAudioService = AudioServiceConfig( androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', androidNotificationChannelName: 'PluriWave Radio', androidNotificationOngoing: true, androidStopForegroundOnPause: true, notificationColor: PluriWaveTokens.brand, androidNotificationIcon: androidNotificationIconResource, ); Future main() async { WidgetsFlutterBinding.ensureInitialized(); // Android Auto browse source: registered FIRST, before any await at all. // It depends on nothing, and everything below it is a potential place to // get stuck — so nothing may sit between engine start and this line. // // Reported: with Android Auto connected, the car screen sometimes came up // black and the app then opened WHITE on the phone until it was // force-killed. `AudioServiceActivity.provideFlutterEngine` returns the // engine from `AudioServicePlugin.getFlutterEngine`, which CREATES the // engine and runs `main()` headlessly the first time — with no Activity — // when the car binds the MediaBrowserService before the app is opened. // `_aplicarPoliticaOrientacion` used to be the first `await` here, and // `SystemChrome.setPreferredOrientations` travels the `flutter/platform` // channel, whose handler (`PlatformPlugin`) is installed by the Activity. // Headless there is nobody to answer it, so `main()` died or hung on line // one: the browse source below was never registered (`getChildren` had no // source -> black car screen) and `runApp` was never reached. Opening the // app then REUSED that same cached, already-dead engine -> white screen, // and only a force-kill (which disposes the engine) recovered it. final fuenteAuto = FuenteEmisorasAutoLocal(); registrarFuenteNavegacion(fuenteAuto); // Cosmetic, and deliberately NOT awaited: a display preference must never // gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies // it again as soon as a real view exists, which is the only moment it can // actually take effect anyway. unawaited(aplicarPoliticaOrientacion()); // S3-R4: single SharedPreferences instance resolved once at startup and // injected into every state/service below. final prefs = await SharedPreferences.getInstance(); // Local-music browse source (Design "getChildren data source // registration"), same injectable-prefs DI convention as every other // startup service — required so `_fuenteMusicaLocalGlobal` is ever // non-null; without this registration the local-music root would stay // permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask). registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs)); // User-saved EQ presets for the car's Ecualizador folder, same // injectable-prefs DI convention and same pre-init placement as the two // registrations above (neither depends on the AudioHandler). Passed as a // read function, not the service, so the folder re-reads on every browse: // a preset saved on the phone appears in the car without an app restart. final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs); registrarFuentePresetsPersonalizados(presetsPersonalizados.listar); // Silent-error channel (fix/notificacion-media): `AudioService.asyncError` // had ZERO subscribers app-wide, and a `PublishSubject` with no listeners // drops what it is given — so every exception `audio_service` catches // internally was discarded without a trace, which is exactly why the // "media notification disappeared" report came with no evidence attached. // Subscribed BEFORE `AudioService.init` below (the getter only touches a // static subject, so it needs no initialisation) so nothing reported // during the MediaBrowser handshake is missed, and placed here rather than // in `conectarHandler` so ONE subscription covers both the on-time and the // degraded/timeout startup paths. final subErroresAudio = observarErroresAudio( AudioService.asyncError, registrar: registrarErrorAudioService, ); // Design "Timeout without re-init": AudioService.init is started exactly // ONCE here and `handlerFuturo` is the only future ever awaited for it — // the plugin caches state internally, so a double-configure call is // unsafe and must never happen, even on the degraded/timeout path below. final handlerFuturo = AudioService.init( builder: () => PluriWaveAudioHandler(), config: configuracionAudioService, ); // S3-R1: audio focus — phone calls / transient losses pause or duck the // radio; headphones unplugged pauses it. Shared by both the on-time and // degraded/late-completion paths below. void conectarHandler(PluriWaveAudioHandler handler) { registrarHandler(handler); // The handler is the only thing this app ever tears down // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels // with it and can never leak — same "register from main.dart" convention // as `registrarHandler` itself. registrarLimpiezaArranque(subErroresAudio.cancel); final sesionAudio = ServicioAudioSession(objetivo: handler); unawaited(sesionAudio.configurar()); } Widget construirApp() => _OrientacionResponsiveApp( child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto), ); final resultado = await esperarArranqueAudio(handlerFuturo); switch (resultado) { case ArranqueAudioListo(:final handler): // Handshake finished in time — exactly today's startup path. conectarHandler(handler); runApp(construirApp()); case ArranqueAudioPendiente( handlerFuturo: final futuroPendiente, ): // Handshake still hung after the timeout: run the app anyway with a // minimal loading bootstrap that keeps awaiting the SAME // `futuroPendiente` — identical to the outer `handlerFuturo`, per // esperarArranqueAudio's contract (never a second AudioService.init // call) — and wires the handler + swaps to the real app once it // eventually resolves. runApp( ArranqueAudioApp( handlerFuturo: futuroPendiente, alListo: conectarHandler, construirApp: (_) => construirApp(), ), ); } } /// Which orientations a display [anchoLogico] dp wide may use: phones stay /// portrait, tablets get everything. Pure, so the policy is testable without /// a platform channel. @visibleForTesting List orientacionesPara(double anchoLogico) => anchoLogico < _anchoMinimoLandscape ? const [DeviceOrientation.portraitUp] : DeviceOrientation.values; /// Applies [orientacionesPara] to the active display. /// /// NEVER throws and never blocks a caller that matters. This runs on the /// headless engine Android Auto starts (see `main`), where the /// `flutter/platform` channel has no handler because there is no Activity to /// install `PlatformPlugin` — so the call can fail with a /// `MissingPluginException` or simply never be answered. Before this guard /// that outcome killed `main()` outright, taking the Android Auto browse /// registration and `runApp` with it. /// /// [aplicar] is injectable so the swallow-everything contract is testable /// without a real platform channel. @visibleForTesting Future aplicarPoliticaOrientacion({ ui.Display? display, Future Function(List)? aplicar, }) async { try { final vista = WidgetsBinding.instance.platformDispatcher.views.isNotEmpty ? WidgetsBinding.instance.platformDispatcher.views.first : null; final displayActivo = display ?? vista?.display; if (displayActivo == null) return; final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio; await (aplicar ?? SystemChrome.setPreferredOrientations)( orientacionesPara(anchoLogico), ); } catch (e) { // Deliberately broad: a cosmetic preference is never worth a failed // startup, and headless is exactly where this fails. developer.log( '[PluriWave] no se pudo aplicar la política de orientación: $e', name: 'Arranque', level: 900, ); } } class _OrientacionResponsiveApp extends StatefulWidget { const _OrientacionResponsiveApp({required this.child}); final Widget child; @override State<_OrientacionResponsiveApp> createState() => _OrientacionResponsiveAppState(); } class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp> with WidgetsBindingObserver { ui.Display? _display; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void didChangeDependencies() { super.didChangeDependencies(); _display = View.maybeOf(context)?.display; unawaited(aplicarPoliticaOrientacion(display: _display)); } @override void didChangeMetrics() { unawaited(aplicarPoliticaOrientacion(display: _display)); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) => widget.child; }