import 'dart:async'; 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 '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(); await _aplicarPoliticaOrientacion(); // S3-R4: single SharedPreferences instance resolved once at startup and // injected into every state/service below. final prefs = await SharedPreferences.getInstance(); // Android Auto browse source (Design "getChildren data source, cold-start // safe") — registered BEFORE the AudioService.init await below (Design // "Reorder handler-independent startup work before the init await"): // neither this nor the local-music registration depends on the // AudioHandler, so browse sources exist for the car even while the // MediaBrowser handshake (no native timeout, see arranque_audio.dart) is // still pending. final fuenteAuto = FuenteEmisorasAutoLocal(); registrarFuenteNavegacion(fuenteAuto); // 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)); // 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(), ), ); } } Future _aplicarPoliticaOrientacion([ui.Display? display]) async { 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; if (anchoLogico < _anchoMinimoLandscape) { await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); return; } await SystemChrome.setPreferredOrientations(DeviceOrientation.values); } 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)); } @override void didChangeMetrics() { unawaited(_aplicarPoliticaOrientacion(_display)); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) => widget.child; }