Two Android Auto regressions reported from the car. 1. The on/off equalizer action disappeared from the playback screen. That was self-inflicted: commitcacd3ecremoved it on the theory that a custom action in `controls` aborts `AudioService.setState` and kills the media notification. Reading the plugin source refutes it. setState (AudioService.java:513-520) SPLITS the list -- a control carrying a customAction goes to `customActions` (PlaybackStateCompat, i.e. the car), everything else becomes a NotificationCompat.Action in `nativeActions` (the phone notification). The two never mix. And the throw the theory depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist under res/drawable, and the labels are non-empty in all 13 locales. The notification outage was already fixed byabc6b47(transient idle on a source change, which setState turns into a full stop() at :557). The action is back, with both state-aware icons. The real invariant -- a custom action's icon must resolve and its label must be non-empty -- is now a test that reads res/drawable and fails on a missing file, instead of a comment claiming custom actions are forbidden outright. 2. The Ecualizador folder never listed the user's saved presets. itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six factory presets appeared -- the user's own were unreachable from the car, the surface where a preset picker matters most. They now arrive through a registered read function (same seam as stations and local music, re-read per browse so a preset saved on the phone shows up without an app restart). presetsEcualizadorAuto is the single source of truth for the ordered universe, used to BUILD the items and to RESOLVE a tap, so the folder cannot show an item that resolution then refuses -- which is what the factory-only default in seleccionarPresetEqPorMediaId would have caused. A custom preset whose name collides with a factory one is dropped: the media id is the raw name, so it could only ever resolve to the factory entry, and an item that applies a preset other than the one it names is worse than an absent one. Tests: 1108 -> 1120.
192 lines
7.5 KiB
Dart
192 lines
7.5 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 '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<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));
|
|
|
|
// 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<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;
|
|
}
|