AudioService.init has no internal timeout and an unhandled onConnectionSuspended case in its MediaBrowser self-bind; under bind contention with the car's connection it can hang forever, so runApp never ran (black car screen, white phone UI until process kill). Race init against an 8s timeout without ever re-calling it: on timeout run a bootstrap app that waits on the same future, wires the handler exactly once when it resolves, reports errors via FlutterError, and swaps to the real app. Auto browse sources now register before the init await since they take no handler dependency.
163 lines
5.9 KiB
Dart
163 lines
5.9 KiB
Dart
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<void> 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));
|
|
|
|
// 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);
|
|
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<PluriWaveAudioHandler>(:final handler):
|
|
// Handshake finished in time — exactly today's startup path.
|
|
conectarHandler(handler);
|
|
runApp(construirApp());
|
|
case ArranqueAudioPendiente<PluriWaveAudioHandler>(
|
|
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<PluriWaveAudioHandler>(
|
|
handlerFuturo: futuroPendiente,
|
|
alListo: conectarHandler,
|
|
construirApp: (_) => construirApp(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _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;
|
|
}
|