Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
1982 lines
84 KiB
Dart
1982 lines
84 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show Locale;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:rxdart/rxdart.dart';
|
|
|
|
import '../estado/estado_entitlement.dart' show esPremiumPersistido;
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/pista_local.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import 'cola_local.dart';
|
|
import 'controlador_reconexion.dart';
|
|
import 'musica_local_auto.dart';
|
|
import 'navegacion_auto.dart';
|
|
import 'servicio_audio_session.dart';
|
|
|
|
/// Estado de reproducción expuesto al UI.
|
|
enum EstadoReproduccion {
|
|
detenido,
|
|
cargando,
|
|
reproduciendo,
|
|
pausado,
|
|
|
|
/// Transient network stall: the handler is retrying with backoff (S7-R2).
|
|
/// UI surfaces it as a loading indicator, never as an error dialog (S7-R3).
|
|
reconectando,
|
|
error,
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Handler global — inicializado en main.dart con AudioService.init
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
PluriWaveAudioHandler? _handlerGlobal;
|
|
|
|
void registrarHandler(PluriWaveAudioHandler handler) {
|
|
_handlerGlobal = handler;
|
|
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved):
|
|
// on the free -> premium transition, actively invalidate every root-level
|
|
// browse id a head unit may have cached while locked, rather than waiting
|
|
// for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc.
|
|
registrarNotificacionDesbloqueoAuto(() {
|
|
handler.notificarHijosCambiaron(AudioService.browsableRootId);
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMisEmisoras);
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMusicaLocal);
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Android Auto browse source — registered from main.dart, mirrors
|
|
// registrarHandler above (Design "getChildren data source registration").
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
FuenteEmisorasAuto? _fuenteNavegacionGlobal;
|
|
|
|
void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
|
|
_fuenteNavegacionGlobal = fuente;
|
|
}
|
|
|
|
/// Local-music browse source — registered from main.dart, mirrors
|
|
/// [registrarFuenteNavegacion] above (Design "getChildren data source
|
|
/// registration"). `null` until registered (headless cold bind before
|
|
/// main.dart's registration line runs) — every consumer below treats a
|
|
/// `null` fuente as "not configured" rather than throwing.
|
|
FuenteMusicaLocalAuto? _fuenteMusicaLocalGlobal;
|
|
|
|
void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
|
_fuenteMusicaLocalGlobal = fuente;
|
|
}
|
|
|
|
/// User-saved EQ presets browse source — registered from main.dart, mirrors
|
|
/// the two registrations above.
|
|
///
|
|
/// On-device feedback: the car's `Ecualizador` folder only ever listed the
|
|
/// six FACTORY presets, so a driver who had carefully saved their own could
|
|
/// not reach it from the car at all — the one place a preset picker is most
|
|
/// useful. The handler owns no persistence, so the list arrives through this
|
|
/// seam exactly like stations and local music do.
|
|
///
|
|
/// A function rather than the service object: the folder needs a fresh read
|
|
/// on every browse (a preset saved on the phone must appear in the car
|
|
/// without an app restart), and this keeps `servicio_audio.dart` from
|
|
/// importing the persistence layer. `null` until registered (headless cold
|
|
/// bind) — consumers fall back to factory presets only, never throw.
|
|
Future<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
|
|
|
|
void registrarFuentePresetsPersonalizados(
|
|
Future<List<PresetEcualizador>> Function() fuente,
|
|
) {
|
|
_fuentePresetsPersonalizadosGlobal = fuente;
|
|
}
|
|
|
|
/// Reads the registered custom-preset source, tolerating both "never
|
|
/// registered" and "the read blew up" as the same empty result: a
|
|
/// diagnostics-grade failure must degrade the folder to its factory presets,
|
|
/// never make browsing fail.
|
|
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
|
|
final fuente = _fuentePresetsPersonalizadosGlobal;
|
|
if (fuente == null) return const [];
|
|
try {
|
|
return await fuente();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// The full ordered preset universe the car's `Ecualizador` folder offers:
|
|
/// the six factory presets first, then [personalizados] in save order.
|
|
///
|
|
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
|
|
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
|
|
/// builds `eq_preset:<nombre>` from it and
|
|
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
|
|
/// two entries sharing a name would produce one media id that can only ever
|
|
/// reach the first of them. Rendering an item that silently applies a
|
|
/// different preset than the one whose name it shows is worse than not
|
|
/// rendering it, and the factory entry is the one the id is guaranteed to
|
|
/// resolve to.
|
|
///
|
|
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
|
|
/// from this list and the tap dispatch resolves against this same list, so
|
|
/// the folder can never show an item that resolution then refuses.
|
|
List<PresetEcualizador> presetsEcualizadorAuto({
|
|
required List<PresetEcualizador> personalizados,
|
|
List<PresetEcualizador>? deFabrica,
|
|
}) {
|
|
final fabrica = deFabrica ?? PresetEcualizador.presets;
|
|
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
|
|
return [
|
|
...fabrica,
|
|
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
|
|
];
|
|
}
|
|
|
|
/// Teardown hook for whatever `main.dart` wired around the handler and must
|
|
/// be undone when the handler itself dies — today only the
|
|
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
|
|
/// from `main.dart`, mirroring [registrarHandler] and the two browse-source
|
|
/// registrations above; run exactly once from
|
|
/// [PluriWaveAudioHandler.onTaskRemoved].
|
|
///
|
|
/// The direction of the dependency matters: the bootstrap layer injects its
|
|
/// cleanup INTO the service layer, so `servicio_audio.dart` never has to
|
|
/// import `arranque_audio.dart` (nor the plugin's static error stream) just to
|
|
/// be able to close it.
|
|
Future<void> Function()? _limpiezaArranqueGlobal;
|
|
|
|
void registrarLimpiezaArranque(Future<void> Function() limpieza) {
|
|
_limpiezaArranqueGlobal = limpieza;
|
|
}
|
|
|
|
/// Free -> premium Android Auto cache-invalidation hook (design.md Open
|
|
/// Questions, orchestrator-resolved): registered from [registrarHandler] so
|
|
/// `estado_entitlement.dart` can trigger it WITHOUT ever touching
|
|
/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a
|
|
/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a
|
|
/// handler registers (headless cold bind, or a widget-only test that never
|
|
/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently.
|
|
void Function()? _alDesbloquearAutoGlobal;
|
|
|
|
/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module
|
|
/// level (like every other `registrar*` seam in this file) purely so tests
|
|
/// can inject a fake hook and assert it fires, without instantiating a real
|
|
/// [PluriWaveAudioHandler].
|
|
void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) {
|
|
_alDesbloquearAutoGlobal = alDesbloquear;
|
|
}
|
|
|
|
/// Fires the registered free -> premium Android Auto invalidation hook, if
|
|
/// any. A no-op before a handler ever registers — never throws.
|
|
void notificarDesbloqueoAuto() {
|
|
_alDesbloquearAutoGlobal?.call();
|
|
}
|
|
|
|
/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design
|
|
/// ADR-4): whether a station-switch dispatch (`playFromMediaId`,
|
|
/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for
|
|
/// [premium]. This is the mandatory BACKSTOP alongside
|
|
/// `respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`) — gating
|
|
/// `getChildren` alone would leave a stale/cached `emisora:<uuid>` tap free
|
|
/// to bypass browsing entirely (android-auto-media spec "Free-Tier Browse
|
|
/// Never Leaks Real Content"). Deliberately does NOT gate `play`/`pause`/
|
|
/// `stop` — transport control of whatever is ALREADY loaded stays free
|
|
/// (Spec "Current-Station Playback Unaffected By Free Tier").
|
|
bool debeBloquearCambioDeEmisora({required bool premium}) => !premium;
|
|
|
|
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
|
|
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
|
|
/// station with no usable favicon gets the SAME on-brand rotating fallback
|
|
/// the browse tree and the car-tap path already show, instead of a blank
|
|
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
|
|
/// dependency — so it is unit-testable without instantiating the handler.
|
|
MediaItem mediaItemParaEmisora(
|
|
Emisora emisora, {
|
|
required AppLocalizations l10n,
|
|
}) {
|
|
return MediaItem(
|
|
id: emisora.url,
|
|
title: localizedStationName(l10n, emisora.nombre),
|
|
artist: emisora.pais ?? '',
|
|
album: 'PluriWave',
|
|
artUri: Uri.parse(artUriPara(emisora)),
|
|
extras: {'uuid': emisora.uuid},
|
|
);
|
|
}
|
|
|
|
/// Reconstructs the phone-side [Emisora] from the handler's current
|
|
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
|
|
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
|
|
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
|
|
/// favicon) is never misread as a genuine station favicon — the phone UI's
|
|
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
|
|
/// (not on `faviconUsable`'s scheme check), so without this guard they would
|
|
/// attempt a doomed network fetch of the fallback's non-http URI before
|
|
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
|
|
/// favicon still round-trips exactly as before. Pure — no handler
|
|
/// dependency — unit-testable directly.
|
|
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
|
|
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
|
final artUriTexto = mediaItem.artUri?.toString();
|
|
return Emisora(
|
|
uuid: uuid,
|
|
nombre: mediaItem.title,
|
|
url: mediaItem.id,
|
|
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
|
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
|
|
);
|
|
}
|
|
|
|
/// Maps a `just_audio` [ProcessingState] to the `audio_service`
|
|
/// [AudioProcessingState] pushed into `playbackState`. Identical to the
|
|
/// previous private `_mapProcState` in every case EXCEPT one:
|
|
/// [ProcessingState.idle] maps to [AudioProcessingState.loading] while
|
|
/// [cambiandoFuente] is `true`.
|
|
///
|
|
/// Why that single exception exists — this is the media-notification
|
|
/// regression, not a cosmetic tweak:
|
|
///
|
|
/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1136`)
|
|
/// calls `AudioService._stop()` — which reaches `stopService()` and cancels
|
|
/// the notification through `deactivateMediaSession()` — on ANY transition
|
|
/// into `idle` from a non-idle state. The notification is posted at exactly
|
|
/// one place, `internalStartForeground()`, reachable only from the
|
|
/// `!wasPlaying && playing` edge, and its FIRST statement is
|
|
/// `ContextCompat.startForegroundService(...)`, which throws
|
|
/// `ForegroundServiceStartNotAllowedException` on API 31+ whenever the
|
|
/// process is not in a foreground state.
|
|
///
|
|
/// Every station change walked straight into that: `_cambiarFuente` pushes
|
|
/// `loading`, then `_recrearPlayer` disposes the old [AudioPlayer] and builds
|
|
/// a FRESH one, and a fresh player's first `playerStateStream` event is
|
|
/// always `idle`. Forwarded verbatim, that is a `loading -> idle` transition,
|
|
/// so the foreground service was torn down mid-source-change and the app then
|
|
/// depended on the following `playing: true` edge to restart it. With the
|
|
/// screen off, on the lock screen, or on an Android Auto / Bluetooth-initiated
|
|
/// start, that restart is exactly the case the platform refuses — audio keeps
|
|
/// playing, the notification never comes back. Self-inflicted, on every API
|
|
/// level, no plugin patch needed: just stop emitting the transient `idle`.
|
|
///
|
|
/// A genuine user stop is unaffected: `stop()` clears the flag BEFORE
|
|
/// `_player.stop()`, so its `idle` still reaches `playbackState` as a real
|
|
/// `idle` and still tears the service down. Pure — no handler dependency — so
|
|
/// the full [ProcessingState] x [cambiandoFuente] matrix is unit-testable
|
|
/// directly.
|
|
AudioProcessingState mapearEstadoProceso(
|
|
ProcessingState proc, {
|
|
required bool cambiandoFuente,
|
|
}) {
|
|
if (cambiandoFuente && proc == ProcessingState.idle) {
|
|
return AudioProcessingState.loading;
|
|
}
|
|
return switch (proc) {
|
|
ProcessingState.idle => AudioProcessingState.idle,
|
|
ProcessingState.loading => AudioProcessingState.loading,
|
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
|
ProcessingState.ready => AudioProcessingState.ready,
|
|
ProcessingState.completed => AudioProcessingState.completed,
|
|
};
|
|
}
|
|
|
|
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
|
|
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
|
|
/// Public consts so tests and this file's own `customAction` dispatch share
|
|
/// the exact same literals; distinct from every browse-tree media-id prefix
|
|
/// in `navegacion_auto.dart` (they live in a completely different
|
|
/// `MediaControl`/`customAction` namespace, never compared against a
|
|
/// media id).
|
|
const accionEqToggle = 'eq_toggle';
|
|
|
|
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
|
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
|
|
/// after the last one. When [actual] is not found in [presets] (e.g. a
|
|
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
|
|
/// starts from the FIRST preset rather than throwing — cycling from an
|
|
/// unknown state always lands somewhere sane. Pure, no I/O.
|
|
///
|
|
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
|
|
/// value, since that field is `static final` (not `const`) and Dart default
|
|
/// parameter values must be compile-time constants.
|
|
PresetEcualizador presetSiguiente(
|
|
PresetEcualizador actual, {
|
|
List<PresetEcualizador>? presets,
|
|
}) {
|
|
final lista = presets ?? PresetEcualizador.presets;
|
|
final indice = lista.indexWhere((p) => p == actual);
|
|
if (indice == -1) return lista.first;
|
|
return lista[(indice + 1) % lista.length];
|
|
}
|
|
|
|
/// Localizes a preset's raw `nombre` for the equalizer custom action's
|
|
/// label (Design "EQ custom actions", item 4) — mirrors
|
|
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
|
|
/// rather than shared: that file is UI-widget layer, this one is the
|
|
/// service/handler layer, and the mapping is a single small switch, not
|
|
/// worth a cross-layer import for). An unrecognized name (e.g. a future
|
|
/// user-named custom preset) falls through to the raw name verbatim.
|
|
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
|
|
return switch (nombre) {
|
|
'Flat' => l10n.equalizerPresetFlat,
|
|
'Rock' => l10n.equalizerPresetRock,
|
|
'Pop' => l10n.equalizerPresetPop,
|
|
'Bass Boost' => l10n.equalizerPresetBassBoost,
|
|
'Jazz' => l10n.equalizerPresetJazz,
|
|
'Voz' => l10n.equalizerPresetVoice,
|
|
'Personalizado' => l10n.equalizerPresetCustom,
|
|
_ => nombre,
|
|
};
|
|
}
|
|
|
|
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
|
|
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
|
|
/// toggle. The previous design paired this with a SECOND action that cycled
|
|
/// through the six factory presets; that action is REMOVED. On-device
|
|
/// feedback: many head units render custom actions icon-first, so two
|
|
/// static, non-parametrized glyphs sitting side by side looked identical/
|
|
/// dead even though the toggle's own icon DID change and the cycle action
|
|
/// DID work — a monochrome icon simply cannot legibly encode "which of six
|
|
/// presets" the way a browsable list's text rows can. Preset selection now
|
|
/// lives in the "Ecualizador" browsable folder instead (see
|
|
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
|
|
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
|
|
/// instead.
|
|
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
|
|
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
|
|
/// without the native Equalizer effect gets no EQ actions at all, not
|
|
/// broken ones.
|
|
///
|
|
/// On-device feedback follow-up: this action used to reuse the SAME
|
|
/// `ic_stat_pluriwave` drawable as everything else and was visually
|
|
/// indistinguishable on a car head unit, which foregrounds the icon over
|
|
/// the label. It now gets its own dedicated drawables
|
|
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
|
|
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
|
|
/// handler dependency.
|
|
List<MediaControl> controlesEcualizadorPersonalizados({
|
|
required bool disponible,
|
|
required bool activo,
|
|
required AppLocalizations l10n,
|
|
}) {
|
|
if (!disponible) return const [];
|
|
return [
|
|
MediaControl.custom(
|
|
androidIcon:
|
|
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
|
|
label:
|
|
activo
|
|
? l10n.eqCustomActionDisableLabel
|
|
: l10n.eqCustomActionEnableLabel,
|
|
name: accionEqToggle,
|
|
),
|
|
];
|
|
}
|
|
|
|
/// The handler's full transport `controls` list for a `playbackState` push.
|
|
///
|
|
/// Top-level and public so tests exercise THIS function rather than a copy of
|
|
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
|
|
/// re-declare the list inline, which meant it stayed green while asserting a
|
|
/// shape `lib/` no longer produced — a guard that cannot see the thing it
|
|
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
|
|
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
|
|
/// pulling the pure part out is the only way to test the real thing.
|
|
///
|
|
/// ORDER MATTERS, and only for the car.
|
|
///
|
|
/// On Android 13+ `createCustomAction` (AudioService.java:466-469) turns
|
|
/// [MediaControl.stop] into a `CUSTOM_ACTION_STOP` custom action too. So on a
|
|
/// modern phone the car receives TWO custom actions, in list order, and a head
|
|
/// unit that exposes a single custom-action slot shows only the first and
|
|
/// buries the rest in an overflow menu — which is why the equalizer toggle
|
|
/// stayed invisible on the playback screen even once it was back in this list
|
|
/// (reported on v1.2.14+136, which does contain it).
|
|
///
|
|
/// The equalizer therefore goes BEFORE `stop`, and wins that slot on purpose:
|
|
/// the car already has its own path to stop playback and Auto's template
|
|
/// renders play/pause itself, while the equalizer is reachable no other way
|
|
/// from this screen.
|
|
///
|
|
/// The phone notification is untouched by that ordering, on every API level.
|
|
/// `setState` (AudioService.java:513-521) splits this list by whether a
|
|
/// control carries a `customAction`: on 13+ `stop` goes to `customActions`
|
|
/// (never the notification) and the equalizer was never in `nativeActions`
|
|
/// anyway; below 13 the equalizer is the only custom action and `stop` stays
|
|
/// native. Either way `nativeActions` comes out as
|
|
/// `[prev?, play/pause, stop, next?]`, and `androidCompactActionIndices`
|
|
/// (`[colaActiva ? 1 : 0]`) still lands on play/pause.
|
|
List<MediaControl> construirControlesTransporte({
|
|
required bool colaActiva,
|
|
required bool playing,
|
|
required bool eqDisponible,
|
|
required bool eqActivo,
|
|
required AppLocalizations l10n,
|
|
}) => [
|
|
if (colaActiva) MediaControl.skipToPrevious,
|
|
if (playing) MediaControl.pause else MediaControl.play,
|
|
...controlesEcualizadorPersonalizados(
|
|
disponible: eqDisponible,
|
|
activo: eqActivo,
|
|
l10n: l10n,
|
|
),
|
|
MediaControl.stop,
|
|
if (colaActiva) MediaControl.skipToNext,
|
|
];
|
|
|
|
/// Content-style extras for the Ecualizador folder's items (decision
|
|
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
|
|
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
|
|
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
|
|
/// are deliberately duplicated across this handler/service layer and the
|
|
/// pure browse-tree builder layer rather than cross-layer-shared). List
|
|
/// style, not grid: these items are short text options with no artwork of
|
|
/// their own, unlike a station or local-track tile.
|
|
const _contentStyleListaEq = {
|
|
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
|
|
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
|
};
|
|
|
|
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
|
|
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
|
|
/// preset must be visibly marked").
|
|
///
|
|
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
|
|
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
|
|
/// and REJECTED as the marking mechanism: this project's `audio_service`
|
|
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
|
|
/// 's list/grid hints are exposed — and the raw platform key itself is
|
|
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
|
|
/// was already listened to"), not item SELECTION; repurposing it here could
|
|
/// render as "already played" on some head units, which would be actively
|
|
/// misleading for a preset picker, and there is no way to verify its actual
|
|
/// rendering on a real head unit from this environment. A plain-text
|
|
/// marker renders identically and unambiguously on every head unit, which
|
|
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
|
|
String _marcarActivoEq(String titulo, {required bool activo}) =>
|
|
activo ? '✓ $titulo' : titulo;
|
|
|
|
/// Builds the "Ecualizador" folder's children for the Android Auto browse
|
|
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
|
|
/// six factory presets in [PresetEcualizador.presets] order, each localized
|
|
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
|
|
/// label already uses, so a preset's name reads identically whether the
|
|
/// driver sees it in the now-playing screen's tooltip or in this folder.
|
|
/// All items are playable: tapping one is dispatched through
|
|
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
|
|
/// dart`), the same seam every other browse-tree leaf already uses; this
|
|
/// folder has no sub-browsing. Exactly one item is marked active via
|
|
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
|
|
/// whichever preset equals [presetActual] — never both at once, and never
|
|
/// zero once this function is reached (an unresolvable [presetActual] with
|
|
/// [activo] `true` simply marks nothing, which cannot happen in practice
|
|
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
|
/// or a "Personalizado" tweak that would just leave every item unmarked
|
|
/// rather than mis-marking one).
|
|
///
|
|
/// [presetsPersonalizados] are the user's own saved presets, appended after
|
|
/// the factory six by [presetsEcualizadorAuto] (which also settles name
|
|
/// collisions). They render through the same [nombrePresetVisible] call as
|
|
/// everything else: that helper passes an unrecognized name through
|
|
/// verbatim, which is exactly right for a name the user typed themselves.
|
|
List<MediaItem> itemsEcualizadorAuto({
|
|
required bool activo,
|
|
required PresetEcualizador presetActual,
|
|
required AppLocalizations l10n,
|
|
List<PresetEcualizador> presetsPersonalizados = const [],
|
|
}) {
|
|
final constructor = ConstructorArbolAuto();
|
|
final items = <MediaItem>[
|
|
MediaItem(
|
|
id: ConstructorArbolAuto.idDesactivarEq,
|
|
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
|
|
playable: true,
|
|
extras: _contentStyleListaEq,
|
|
),
|
|
];
|
|
for (final preset in presetsEcualizadorAuto(
|
|
personalizados: presetsPersonalizados,
|
|
)) {
|
|
items.add(
|
|
MediaItem(
|
|
id: constructor.idPresetEq(preset.nombre),
|
|
title: _marcarActivoEq(
|
|
nombrePresetVisible(l10n, preset.nombre),
|
|
activo: activo && preset == presetActual,
|
|
),
|
|
playable: true,
|
|
extras: _contentStyleListaEq,
|
|
),
|
|
);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/// Wrapper de alto nivel para el UI.
|
|
class ServicioAudio {
|
|
PluriWaveAudioHandler get _handler {
|
|
assert(
|
|
_handlerGlobal != null,
|
|
'registrarHandler() no fue llamado en main.dart',
|
|
);
|
|
return _handlerGlobal!;
|
|
}
|
|
|
|
Emisora? get emisoraActual => _handler.emisoraActual;
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_handler.configurarLocalizaciones(l10n);
|
|
}
|
|
|
|
Stream<EstadoReproduccion> get estadoStream =>
|
|
_handler.playbackState.map((s) {
|
|
if (s.processingState == AudioProcessingState.error) {
|
|
return EstadoReproduccion.error;
|
|
}
|
|
if (_handler.reconectando) return EstadoReproduccion.reconectando;
|
|
if (s.processingState == AudioProcessingState.loading ||
|
|
s.processingState == AudioProcessingState.buffering) {
|
|
return EstadoReproduccion.cargando;
|
|
}
|
|
if (s.playing) return EstadoReproduccion.reproduciendo;
|
|
if (s.processingState == AudioProcessingState.idle) {
|
|
return EstadoReproduccion.detenido;
|
|
}
|
|
return EstadoReproduccion.pausado;
|
|
});
|
|
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
final item = mediaItemParaEmisora(
|
|
emisora,
|
|
l10n: lookupAppLocalizations(const Locale('es')),
|
|
);
|
|
await _handler.playMediaItem(item);
|
|
}
|
|
|
|
Future<void> pausar() => _handler.pause();
|
|
Future<void> reanudar() => _handler.play();
|
|
|
|
Future<void> togglePlay() async {
|
|
if (_handler.playbackState.value.playing) {
|
|
await pausar();
|
|
} else {
|
|
await reanudar();
|
|
}
|
|
}
|
|
|
|
Future<void> detener() => _handler.stop();
|
|
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
|
|
double get volumen => _handler.volumen;
|
|
bool get estaSonando => _handler.playbackState.value.playing;
|
|
Stream<int?> get androidAudioSessionIdStream async* {
|
|
yield _handler.androidAudioSessionId;
|
|
yield* _handler.androidAudioSessionIdStream;
|
|
}
|
|
|
|
Future<void> dispose() async {}
|
|
|
|
// ── Ecualizador ───────────────────────────────────────────────────────────
|
|
AndroidEqualizer? get ecualizador => _handler.ecualizador;
|
|
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
|
|
PresetEcualizador get presetActual => _handler.presetActual;
|
|
|
|
Future<void> aplicarPreset(PresetEcualizador preset) =>
|
|
_handler.aplicarPreset(preset);
|
|
Future<void> setEcualizadorActivo(bool activo) =>
|
|
_handler.setEcualizadorActivo(activo);
|
|
|
|
Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AudioHandler
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class PluriWaveAudioHandler extends BaseAudioHandler
|
|
with SeekHandler
|
|
implements ObjetivoAudioInterrumpible {
|
|
static const _timeoutCambioFuente = Duration(seconds: 12);
|
|
static const _timeoutCierrePlayer = Duration(seconds: 3);
|
|
static const _factorAtenuacion = 0.3;
|
|
|
|
// ── Live-stream buffer (Design 7.1, S7-R1) ────────────────────────────────
|
|
// Forward jitter cushion for live radio: there is no rewind history, so the
|
|
// buffer only absorbs short drops (up to roughly what was buffered when the
|
|
// drop hit); on reconnect we rejoin the live edge.
|
|
static const bufferMinimo = Duration(seconds: 15);
|
|
static const bufferMaximo = Duration(seconds: 50);
|
|
static const bufferParaIniciar = Duration(milliseconds: 2500);
|
|
static const bufferTrasRebuffer = Duration(seconds: 5);
|
|
|
|
/// Buffer configuration applied at [AudioPlayer] construction. Exposed so
|
|
/// tests can assert the values without touching platform channels (S7-R1).
|
|
static const configuracionCargaAndroid = AudioLoadConfiguration(
|
|
androidLoadControl: AndroidLoadControl(
|
|
minBufferDuration: bufferMinimo,
|
|
maxBufferDuration: bufferMaximo,
|
|
bufferForPlaybackDuration: bufferParaIniciar,
|
|
bufferForPlaybackAfterRebufferDuration: bufferTrasRebuffer,
|
|
prioritizeTimeOverSizeThresholds: true,
|
|
),
|
|
);
|
|
|
|
AndroidEqualizer _eq = AndroidEqualizer();
|
|
late AudioPlayer _player = _crearPlayer();
|
|
StreamSubscription<PlayerState>? _estadoPlayerSub;
|
|
StreamSubscription<Duration>? _bufferedSub;
|
|
StreamSubscription<Duration?>? _duracionSub;
|
|
StreamSubscription<PlaybackEvent>? _eventosSub;
|
|
StreamSubscription<int?>? _androidAudioSessionIdSub;
|
|
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
|
|
int? _androidAudioSessionId;
|
|
|
|
/// Last session id processed for EQ re-apply purposes (Design "Change-guard
|
|
/// field separate from broadcast field"). Kept apart from
|
|
/// [_androidAudioSessionId] so external broadcast semantics on
|
|
/// [androidAudioSessionIdStream] stay untouched by the EQ re-apply guard.
|
|
int? _ultimaSessionIdEq;
|
|
Future<void> _colaCambioFuente = Future<void>.value();
|
|
int _revisionFuente = 0;
|
|
|
|
/// `true` only for the window inside [_cambiarFuente] where the OLD player
|
|
/// has been disposed and the FRESH one has not loaded its URL yet — the
|
|
/// window in which `playerStateStream` unavoidably emits a transient
|
|
/// `idle` that is NOT a stop. [mapearEstadoProceso] masks that one `idle`
|
|
/// as `loading` so `audio_service` does not tear the foreground service
|
|
/// (and with it the media notification) down mid-source-change; see that
|
|
/// function's doc for the full mechanism.
|
|
///
|
|
/// A value stuck at `true` is the ONLY risk this flag introduces: a real
|
|
/// user stop would then be masked away from `idle` and the service would
|
|
/// never stop, leaving an unkillable notification. It is therefore cleared
|
|
/// by a `finally` in [_cambiarFuente] (which covers normal completion,
|
|
/// both revision-mismatch `return`s, every `rethrow`, and any non-`Exception`
|
|
/// `Error` that no catch clause matches), AND eagerly at the top of every
|
|
/// catch clause, AND at the start of [stop] and [_gestionarErrorReproduccion]
|
|
/// — i.e. before every single `_player.stop()` call in this class.
|
|
bool _cambiandoFuente = false;
|
|
|
|
/// Active local-music queue (Design "single load-bearing invariant"):
|
|
/// `null` means "not local-queue playback" — the ONLY gate the
|
|
/// auto-advance/skip/isolation logic reads. Radio never sets this field.
|
|
ColaLocal? _colaLocal;
|
|
|
|
/// Re-entry latch (Design ADR-3): armed synchronously the instant an
|
|
/// auto-advance is triggered, cleared when the next track reaches
|
|
/// `playing && ready`, or on deactivate/stop/external play. Guards
|
|
/// against a double-advance from repeated `completed` emissions during
|
|
/// the async URI-resolve gap.
|
|
bool _avanzandoCola = false;
|
|
|
|
Emisora? emisoraActual;
|
|
double _volumen = 1.0;
|
|
double get volumen => _volumen;
|
|
AppLocalizations? _l10n;
|
|
|
|
/// Intent-to-play flag (Designs 3.1/7.2): reflects the LAST explicit
|
|
/// intent (play/pause/stop, including audio-session interruptions, which
|
|
/// pause through [pausar]). The S7 reconnect state machine reads it to
|
|
/// distinguish a network stall from an intentional pause.
|
|
bool _intencionReproducir = false;
|
|
|
|
/// Ducked state requested by the audio session (transient focus loss).
|
|
bool _atenuado = false;
|
|
|
|
/// Reconnect-on-stall state machine (Design 7.2, S7-R2).
|
|
final ControladorReconexion _reconexion = ControladorReconexion();
|
|
|
|
/// Per-`parentMediaId` "children changed" subjects (iap-freemium-unlock,
|
|
/// design.md Open Questions): `audio_service`'s OWN internal listener
|
|
/// (registered once `AudioService.init` completes) subscribes to
|
|
/// [subscribeToChildren] and forwards every new value to the platform's
|
|
/// `notifyChildrenChanged` — the plugin's top-level `notifyChildrenChanged`
|
|
/// helper is deprecated precisely in favor of this stream-based path. A
|
|
/// `BehaviorSubject` per id, created lazily on first subscription;
|
|
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
|
|
/// value to trigger the platform notification for that id.
|
|
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
|
|
|
|
@override
|
|
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
|
_childrenSubjects.putIfAbsent(
|
|
parentMediaId,
|
|
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
|
|
);
|
|
|
|
/// Invalidates a head unit's cached browse listing for [parentMediaId]
|
|
/// (Design "Open Questions" — actively invalidate on the free -> premium
|
|
/// transition rather than waiting for the head unit's own re-bind). A
|
|
/// no-op if nothing ever subscribed to this id.
|
|
void notificarHijosCambiaron(String parentMediaId) {
|
|
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
|
|
}
|
|
|
|
/// True while the handler is inside the reconnect window. [ServicioAudio]
|
|
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
|
|
/// indicator instead of an error during retries (S7-R3).
|
|
bool _reconectando = false;
|
|
bool get reconectando => _reconectando;
|
|
|
|
AndroidEqualizer? get ecualizador => _eq;
|
|
bool _eqDisponible = false;
|
|
bool get ecualizadorDisponible => _eqDisponible;
|
|
bool _ecualizadorActivo = true;
|
|
bool get ecualizadorActivo => _ecualizadorActivo;
|
|
|
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
|
PresetEcualizador get presetActual => _presetActual;
|
|
int? get androidAudioSessionId => _androidAudioSessionId;
|
|
Stream<int?> get androidAudioSessionIdStream =>
|
|
_androidAudioSessionIdController.stream;
|
|
|
|
PluriWaveAudioHandler() {
|
|
_conectarStreamsPlayer();
|
|
}
|
|
|
|
AppLocalizations get _textos {
|
|
final actual = _l10n;
|
|
if (actual != null) return actual;
|
|
return lookupAppLocalizations(const Locale('es'));
|
|
}
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_l10n = l10n;
|
|
}
|
|
|
|
AudioPlayer _crearPlayer() {
|
|
return AudioPlayer(
|
|
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
|
|
audioLoadConfiguration: configuracionCargaAndroid,
|
|
);
|
|
}
|
|
|
|
void _conectarStreamsPlayer() {
|
|
_estadoPlayerSub = _player.playerStateStream.listen((state) {
|
|
final playing = state.playing;
|
|
final proc = state.processingState;
|
|
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
|
|
// double-gated on `completed` + an active local queue, so this is a
|
|
// no-op for radio (which never emits `completed`) and for
|
|
// single-track local playback (which never sets `_colaLocal`).
|
|
_manejarFinPista(proc);
|
|
if (playing && proc == ProcessingState.ready) {
|
|
// Successful (re)connection: reset the backoff so the next stall
|
|
// starts over, and leave the reconnect window (S7-R7).
|
|
_reconexion.restablecer();
|
|
_reconectando = false;
|
|
// Local queue (Design ADR-3): the next queued track reached a
|
|
// stable playing state — clear the re-entry latch so a LATER
|
|
// completion can advance again. A no-op for radio, which never
|
|
// sets `_avanzandoCola`.
|
|
_avanzandoCola = false;
|
|
}
|
|
// Local queue transport (Design "Transport wiring"): skip controls
|
|
// are only offered while a queue is active — when `_colaLocal` is
|
|
// `null` this list/set/index is byte-identical to the pre-change
|
|
// radio behavior (regression guard).
|
|
final colaActiva = _colaLocal != null;
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
controls: _controlesTransporte(
|
|
colaActiva: colaActiva,
|
|
playing: playing,
|
|
),
|
|
// Android for Cars, "Enable playback control": «Android Auto and
|
|
// AAOS display playback controls based on the actions that are
|
|
// enabled in the PlaybackStateCompat object. By default, your app
|
|
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
|
|
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
|
//
|
|
// This set had carried only `seek` + `stop` since the very first
|
|
// commit, so the required transport actions were never advertised.
|
|
// The car got away with it for a long time — but Android Auto is a
|
|
// separate app that updates itself, so a tolerance it used to have
|
|
// can disappear without a single line changing on our side. That
|
|
// matches the report exactly: "it used to work, and in the latest
|
|
// versions it doesn't", with no audio commit in between that could
|
|
// explain it.
|
|
//
|
|
// The phone notification never depended on any of this: it builds
|
|
// its play/pause button from `controls`, which is why the symptom
|
|
// is car-only.
|
|
systemActions: {
|
|
MediaAction.play,
|
|
MediaAction.pause,
|
|
MediaAction.playPause,
|
|
MediaAction.stop,
|
|
MediaAction.playFromMediaId,
|
|
MediaAction.playFromSearch,
|
|
MediaAction.seek,
|
|
// Previous/next are advertised ALWAYS now, not only for a local
|
|
// queue. Android Auto reserves those two slots and only hands the
|
|
// space to custom actions when the app declares no support, so
|
|
// this is what puts prev/next on the car's transport row -- and
|
|
// `skipToNext`/`skipToPrevious` fall back to station-to-station
|
|
// skipping when there is no queue, so neither button is inert.
|
|
MediaAction.skipToPrevious,
|
|
MediaAction.skipToNext,
|
|
},
|
|
androidCompactActionIndices: [colaActiva ? 1 : 0],
|
|
processingState: mapearEstadoProceso(
|
|
proc,
|
|
cambiandoFuente: _cambiandoFuente,
|
|
),
|
|
playing: playing,
|
|
// Reported: in Android Auto the progress bar and the time labels of
|
|
// a local track never move. `updatePosition` was NEVER set anywhere
|
|
// in this file, so it stayed at its `Duration.zero` default while
|
|
// `copyWith` refreshed `updateTime` to now on every push
|
|
// (audio_service.dart:411-413, :256). A client extrapolates
|
|
// `updatePosition + (now - updateTime) * speed`, so it was told
|
|
// "position 0, as of right now" over and over — a bar pinned at the
|
|
// start. The phone UI never noticed because it reads
|
|
// `_player.positionStream` directly.
|
|
updatePosition: _player.position,
|
|
bufferedPosition: _player.bufferedPosition,
|
|
speed: _player.speed,
|
|
),
|
|
);
|
|
_trazarEstadoPublicado();
|
|
});
|
|
|
|
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
bufferedPosition: pos,
|
|
// Must ride along: `copyWith` stamps a fresh `updateTime` but keeps
|
|
// the old `updatePosition`, so a push without it actively tells the
|
|
// client the PREVIOUS position is current NOW — freezing the bar
|
|
// between player-state events. This stream ticks ~2/s, which is
|
|
// what keeps the car's bar smooth.
|
|
updatePosition: _player.position,
|
|
),
|
|
);
|
|
});
|
|
|
|
// Duration arrives asynchronously once the source is parsed, and Android
|
|
// Auto draws no progress bar for a MediaItem without one. Radio streams
|
|
// report null (correct: live audio has no length) and are left alone.
|
|
_duracionSub = _player.durationStream.listen((duracion) {
|
|
final actual = mediaItem.value;
|
|
if (duracion == null || actual == null) return;
|
|
if (actual.duration == duracion) return;
|
|
mediaItem.add(actual.copyWith(duration: duracion));
|
|
});
|
|
|
|
_eventosSub = _player.playbackEventStream.listen(
|
|
(_) {},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
_gestionarErrorReproduccion(error);
|
|
},
|
|
);
|
|
|
|
_androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
|
|
sessionId,
|
|
) {
|
|
_androidAudioSessionId = sessionId;
|
|
if (!_androidAudioSessionIdController.isClosed) {
|
|
_androidAudioSessionIdController.add(sessionId);
|
|
}
|
|
if (debeReaplicarEcualizador(
|
|
sessionId: sessionId,
|
|
ultimaSessionIdEq: _ultimaSessionIdEq,
|
|
eqDisponible: _eqDisponible,
|
|
)) {
|
|
_ultimaSessionIdEq = sessionId;
|
|
unawaited(_activarEcualizador());
|
|
}
|
|
});
|
|
}
|
|
|
|
String? _ultimaTrazaEstado;
|
|
|
|
/// Logs the state actually handed to `AudioService.setState`, once per real
|
|
/// change (this fires on every player event, so unconditional logging would
|
|
/// bury the signal).
|
|
///
|
|
/// Exists for one open question that static reading could not settle: the
|
|
/// Android Auto playback screen shows PLAY while a station is audibly
|
|
/// playing. The car does NOT take that icon from `controls` — it takes it
|
|
/// from `PlaybackStateCompat.getState()` (AudioService.java:601-611), where
|
|
/// `ready` + `playing` is the only combination that yields `STATE_PLAYING`;
|
|
/// `idle` gives `STATE_NONE`, which is what a freshly created session
|
|
/// carries (:319) and what a car would render as a play button. Every
|
|
/// `playbackState.add` in this file was audited and none publishes
|
|
/// `playing: false` while audio runs, so the failing input is unknown and
|
|
/// any fix would be guesswork.
|
|
///
|
|
/// `eqDisponible` rides along because the equalizer custom action is gated
|
|
/// on it and the flag is otherwise unobservable — one car session answers
|
|
/// both questions at once:
|
|
///
|
|
/// adb logcat | grep PluriWave
|
|
///
|
|
/// It uses [debugPrint] and NOT `dart:developer`'s `log`, and that is not a
|
|
/// style choice. `log()` writes to the VM service, which a RELEASE build
|
|
/// does not have — so this trace, and every error line in this file, was
|
|
/// invisible in the only build that ever runs in a car. Weeks of "no
|
|
/// evidence" were this, not a quiet app. Do not convert these back.
|
|
void _trazarEstadoPublicado() {
|
|
final s = playbackState.value;
|
|
final traza =
|
|
'proc=${s.processingState.name} playing=${s.playing} '
|
|
'eqDisponible=$_eqDisponible eqActivo=$_ecualizadorActivo '
|
|
'custom=${s.controls.where((c) => c.customAction != null).length} '
|
|
'controles=${s.controls.length}';
|
|
if (traza == _ultimaTrazaEstado) return;
|
|
_ultimaTrazaEstado = traza;
|
|
debugPrint('[PluriWave][ServicioAudio] estado $traza');
|
|
}
|
|
|
|
/// Binds [construirControlesTransporte] — which holds the whole contract,
|
|
/// including why the equalizer must precede `stop` — to this handler's live
|
|
/// equalizer state.
|
|
List<MediaControl> _controlesTransporte({
|
|
required bool colaActiva,
|
|
required bool playing,
|
|
}) => construirControlesTransporte(
|
|
colaActiva: colaActiva,
|
|
playing: playing,
|
|
eqDisponible: _eqDisponible,
|
|
eqActivo: _ecualizadorActivo,
|
|
l10n: _textos,
|
|
);
|
|
|
|
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
|
|
/// called whenever EQ availability/enabled state changes outside a
|
|
/// player-state transition (a custom-action tap, or a phone-side toggle),
|
|
/// so the equalizer action's icon and label stay in sync on the car's
|
|
/// now-playing screen without waiting for an unrelated player event.
|
|
/// Idempotent and cheap (no native calls) — safe to call from any EQ
|
|
/// state-changing path.
|
|
void _actualizarControlesEq() {
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
controls: _controlesTransporte(
|
|
colaActiva: _colaLocal != null,
|
|
playing: playbackState.value.playing,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Gestiona cualquier error de reproducción de ExoPlayer.
|
|
///
|
|
/// Network-class failures while the user still intends to play enter the
|
|
/// reconnect state machine (S7-R2) instead of surfacing a terminal error;
|
|
/// only retry exhaustion (or non-network errors) falls through to the
|
|
/// existing error path, so the user sees a single error — no spam per retry.
|
|
void _gestionarErrorReproduccion(Object error) {
|
|
// Terminal-error path also ends in `_player.stop()` below, and it is
|
|
// reachable from `_eventosSub`'s `onError` WHILE a source change is still
|
|
// in flight. Dropping the mask here keeps the invariant total: the flag
|
|
// is `false` before every `_player.stop()` call in this class.
|
|
_cambiandoFuente = false;
|
|
if (_intentarReconexion(error)) return;
|
|
|
|
String mensaje;
|
|
String codigoLog;
|
|
|
|
if (error is PlayerException) {
|
|
codigoLog = 'PlayerException(code=${error.code}): ${error.message}';
|
|
mensaje = _mensajeAmigable(error);
|
|
} else if (error is TimeoutException) {
|
|
codigoLog = 'TimeoutException: $error';
|
|
mensaje = _textos.audioErrorTimeout;
|
|
} else {
|
|
codigoLog = 'Error desconocido: $error';
|
|
mensaje = _textos.audioErrorGeneric;
|
|
}
|
|
|
|
debugPrint('[PluriWave][ServicioAudio] Error reproducción: $codigoLog');
|
|
|
|
_detenerReconexion();
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.error,
|
|
playing: false,
|
|
errorMessage: mensaje,
|
|
),
|
|
);
|
|
// The failed item and station are KEPT, deliberately.
|
|
//
|
|
// This used to do `emisoraActual = null; mediaItem.add(null);`, which
|
|
// left the media session in STATE_ERROR with no metadata at all. Android
|
|
// Auto drops a session with nothing to show, which is what made PluriWave
|
|
// vanish from the car pane the moment a station failed -- reported as
|
|
// "if a station fails it seems to crash, and going to 1/3 it fails".
|
|
//
|
|
// Keeping them costs nothing on the phone (nothing outside this file
|
|
// consumes `mediaItem`, verified) and buys two things in the car: the
|
|
// screen can still name the station that failed instead of going blank,
|
|
// and previous/next stay usable, so the driver skips out of a dead
|
|
// station instead of being stranded -- `_saltarEmisora` needs
|
|
// `emisoraActual` to know where it is in the list.
|
|
//
|
|
// The error state itself is unchanged: STATE_ERROR with the message.
|
|
_player.stop().catchError((_) {});
|
|
}
|
|
|
|
/// Network-class failures: ExoPlayer 2xxx source errors (no internet, bad
|
|
/// URL/host, timeout) and our own source-change timeout guard.
|
|
bool _esErrorDeRed(Object error) =>
|
|
(error is PlayerException && error.code >= 2000 && error.code < 3000) ||
|
|
error is TimeoutException;
|
|
|
|
/// Attempts to enter (or stay in) the reconnect window. Returns true when a
|
|
/// retry was scheduled and the terminal error path must be skipped.
|
|
bool _intentarReconexion(Object error) {
|
|
if (!_esErrorDeRed(error)) return false;
|
|
final item = mediaItem.value;
|
|
if (item == null) return false;
|
|
|
|
final decision = _reconexion.registrarFallo(
|
|
intencionReproducir: _intencionReproducir,
|
|
alReintentar: () => _reintentarFuente(item),
|
|
);
|
|
if (decision != DecisionReconexion.reintentar) {
|
|
// ignorar (user pause/stop or interruption) keeps the player quiet;
|
|
// agotado falls through to the single terminal error (S7-R2-C).
|
|
if (decision == DecisionReconexion.ignorar) {
|
|
_reconectando = false;
|
|
}
|
|
return decision == DecisionReconexion.ignorar;
|
|
}
|
|
|
|
_reconectando = true;
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] Stall de red, reintento ${_reconexion.intentos}/'
|
|
'${_reconexion.maxReintentos} en '
|
|
'${_reconexion.retrasoParaIntento(_reconexion.intentos).inSeconds}s',
|
|
);
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.buffering,
|
|
playing: false,
|
|
errorMessage: null,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/// Re-issues the live source through the revision-guarded source-change
|
|
/// queue, so a user source switch or stop during the retry cancels it.
|
|
void _reintentarFuente(MediaItem item) {
|
|
if (!_intencionReproducir) {
|
|
_detenerReconexion();
|
|
return;
|
|
}
|
|
final revision = ++_revisionFuente;
|
|
_colaCambioFuente = _colaCambioFuente
|
|
.catchError((_) {})
|
|
.then((_) => _cambiarFuente(item, revision))
|
|
// Failures already routed through _gestionarErrorReproduccion, which
|
|
// schedules the next backoff retry or surfaces the terminal error.
|
|
.catchError((_) {});
|
|
}
|
|
|
|
void _detenerReconexion() {
|
|
_reconexion.cancelar();
|
|
_reconectando = false;
|
|
}
|
|
|
|
/// Traduce códigos de error de ExoPlayer a mensajes para el usuario.
|
|
String _mensajeAmigable(PlayerException e) {
|
|
final code = e.code;
|
|
|
|
if (code >= 2000 && code < 3000) {
|
|
if (code == 2001) return _textos.audioErrorNoInternet;
|
|
if (code == 2002) return _textos.audioErrorInvalidUrl;
|
|
if (code == 2003) return _textos.audioErrorNotFound;
|
|
if (code == 2004) return _textos.audioErrorTimeout;
|
|
return _textos.audioErrorCannotConnect;
|
|
}
|
|
|
|
if (code >= 3000 && code < 4000) {
|
|
return _textos.audioErrorUnsupportedFormat;
|
|
}
|
|
|
|
if (code >= 4000 && code < 5000) {
|
|
return _textos.audioErrorDecode;
|
|
}
|
|
|
|
final msg = e.message ?? '';
|
|
if (msg.contains('Cleartext') || msg.contains('cleartext')) {
|
|
return _textos.audioErrorCleartext;
|
|
}
|
|
if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) {
|
|
return _textos.audioErrorSsl;
|
|
}
|
|
|
|
return _textos.audioErrorCannotPlay;
|
|
}
|
|
|
|
/// Public entry point for EVERY external play (phone `reproducir`, car
|
|
/// `emisora:`/`grupo:`/`pista:` non-path). ALWAYS clears the
|
|
/// local queue FIRST (Design ADR-2, the single load-bearing invariant:
|
|
/// "external play = leave queue mode") so a stale auto-advance can never
|
|
/// fire after an external source switch, then delegates to the private
|
|
/// [_encolarCambioFuente] — the SAME source-change path queue playback
|
|
/// uses via [_reproducirEntradaCola].
|
|
@override
|
|
Future<void> playMediaItem(MediaItem mediaItem) async {
|
|
_colaLocal = null;
|
|
_avanzandoCola = false;
|
|
return _encolarCambioFuente(mediaItem);
|
|
}
|
|
|
|
/// The revision-guarded source-change queue (Design ADR-1/ADR-2), shared
|
|
/// by public [playMediaItem] (which clears `_colaLocal` first) and
|
|
/// [_reproducirEntradaCola] (queue play, which does NOT clear it) — the
|
|
/// body is exactly [playMediaItem]'s previous implementation, unchanged.
|
|
Future<void> _encolarCambioFuente(MediaItem mediaItem) async {
|
|
_intencionReproducir = true;
|
|
// Fresh user play/source switch: restart the backoff from scratch and
|
|
// leave any previous reconnect window (S7-R2).
|
|
_reconexion.restablecer();
|
|
_reconectando = false;
|
|
final revision = ++_revisionFuente;
|
|
_colaCambioFuente = _colaCambioFuente
|
|
.catchError((_) {})
|
|
.then((_) => _cambiarFuente(mediaItem, revision));
|
|
return _colaCambioFuente;
|
|
}
|
|
|
|
/// Plays a single local-queue track WITHOUT clearing `_colaLocal` (Design
|
|
/// ADR-2's "queue play" transition) — the ONLY other caller of
|
|
/// [_encolarCambioFuente] besides public [playMediaItem]. Callers are
|
|
/// responsible for setting/keeping `_colaLocal` themselves before calling
|
|
/// this (queue start, auto-advance, skip) — this method itself never
|
|
/// touches the field on the happy path.
|
|
///
|
|
/// [colaEsperada], when provided, is the mid-await race guard (Design
|
|
/// ADR-3's advance flow): after resolving [nodo]'s content URI (the
|
|
/// async gap where a user action could replace `_colaLocal`), playback
|
|
/// only proceeds when `_colaLocal` is still IDENTICAL to [colaEsperada].
|
|
/// Skip/queue-start callers omit it (no prior async gap to guard).
|
|
Future<void> _reproducirEntradaCola(
|
|
NodoLocal nodo, {
|
|
ColaLocal? colaEsperada,
|
|
}) async {
|
|
final fuente = _fuenteMusicaLocalGlobal;
|
|
if (fuente == null) {
|
|
if (colaEsperada != null) _avanzandoCola = false;
|
|
return;
|
|
}
|
|
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
|
|
if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) {
|
|
// Stale: a user action (external play, stop, another skip) replaced
|
|
// `_colaLocal` during the await — abort this advance without
|
|
// touching whatever the newer action already set.
|
|
return;
|
|
}
|
|
if (item == null) {
|
|
// The local track's content URI could not be resolved (revoked
|
|
// permission, moved/deleted file). Only an in-flight advance owns
|
|
// the latch here (a direct skip/queue-start never armed it) — end
|
|
// the queue cleanly instead of leaving `_avanzandoCola` stuck.
|
|
if (colaEsperada != null) {
|
|
_avanzandoCola = false;
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
}
|
|
return;
|
|
}
|
|
await _encolarCambioFuente(item);
|
|
}
|
|
|
|
/// Clears the local queue (Design ADR-2/ADR-4): shared by [stop], the
|
|
/// end-of-queue path, and a resolve failure mid-advance.
|
|
void _desactivarCola() {
|
|
_colaLocal = null;
|
|
_avanzandoCola = false;
|
|
}
|
|
|
|
/// Sets up a fresh local-music queue and plays its first track (Design
|
|
/// "Data Flow" — the `iniciarCola` seam [playFromMediaId] passes to
|
|
/// [reproducirCarpetaLocal]).
|
|
Future<void> _iniciarColaLocal(List<NodoLocal> pistas) async {
|
|
final cola = ColaLocal(pistas: pistas);
|
|
_colaLocal = cola;
|
|
await _reproducirEntradaCola(cola.actual);
|
|
}
|
|
|
|
/// First line of the `playerStateStream` listener (Design ADR-3, Phase 3
|
|
/// task 3.3): pure-decision-driven queue-advance handling. A no-op for
|
|
/// radio (never emits `completed`) and single-track local playback
|
|
/// (never sets `_colaLocal`) — see [decidirAvanceCola]'s doc comment for
|
|
/// the full gating contract.
|
|
void _manejarFinPista(ProcessingState proc) {
|
|
final decision = decidirAvanceCola(
|
|
colaLocal: _colaLocal,
|
|
avanzandoCola: _avanzandoCola,
|
|
trackCompletado: proc == ProcessingState.completed,
|
|
);
|
|
// Reported: a local track sometimes jumped to another one on its own,
|
|
// without reaching the end and without anyone pressing a thing. Reading
|
|
// the code cannot settle it -- an advance here requires a genuine
|
|
// `completed` from just_audio, so either the player reports the end
|
|
// early (plausible for a `content://` SAF source, whose duration is not
|
|
// always exact) or something else moved the track. Rather than guess,
|
|
// log the transition with the state that caused it, so the next report
|
|
// arrives with the reason attached instead of another hypothesis.
|
|
if (decision != DecisionAvanceCola.ninguna) {
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] avance de cola decision=${decision.name} '
|
|
'proc=${proc.name} pos=${_player.position} dur=${_player.duration} '
|
|
'pista=${mediaItem.value?.title}',
|
|
);
|
|
}
|
|
switch (decision) {
|
|
case DecisionAvanceCola.ninguna:
|
|
return;
|
|
case DecisionAvanceCola.desactivar:
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
return;
|
|
case DecisionAvanceCola.avanzar:
|
|
final siguiente = _colaLocal!.conSiguiente();
|
|
if (siguiente == null) {
|
|
// Structurally unreachable — decidirAvanceCola already proved
|
|
// conSiguiente() != null for `avanzar` — guarded defensively.
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
return;
|
|
}
|
|
// Set the re-entry latch synchronously, before any await, so a
|
|
// second rapid `completed` emission is caught by
|
|
// decidirAvanceCola's `avanzandoCola == true` gate (Design
|
|
// ADR-3).
|
|
_avanzandoCola = true;
|
|
_colaLocal = siguiente;
|
|
unawaited(
|
|
_reproducirEntradaCola(siguiente.actual, colaEsperada: siguiente),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
|
|
this.mediaItem.add(mediaItem);
|
|
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.loading,
|
|
playing: false,
|
|
errorMessage: null,
|
|
),
|
|
);
|
|
// Opens the masking window BEFORE `_recrearPlayer`, which is what
|
|
// disposes the old player and constructs the fresh one whose first
|
|
// `playerStateStream` event is the transient `idle` we must not forward
|
|
// (see [mapearEstadoProceso]).
|
|
_cambiandoFuente = true;
|
|
try {
|
|
await _recrearPlayer();
|
|
if (revision != _revisionFuente) return;
|
|
|
|
await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente);
|
|
// Source swap complete: the fresh player's transient `idle` is behind
|
|
// us, so stop masking immediately — before anything below can await —
|
|
// and let a real `idle` through again from here on.
|
|
_cambiandoFuente = false;
|
|
if (revision != _revisionFuente) return;
|
|
|
|
_iniciarPlaySinBloquear(mediaItem, revision);
|
|
unawaited(_activarEcualizador());
|
|
} on PlayerException catch (e) {
|
|
// Cleared BEFORE `_gestionarErrorReproduccion`, not just by the
|
|
// `finally`: that method calls `_player.stop()` without awaiting it, so
|
|
// the resulting `idle` could otherwise land while the mask was still
|
|
// up and be rewritten to `loading`. Same reason in the two clauses
|
|
// below.
|
|
_cambiandoFuente = false;
|
|
if (revision == _revisionFuente) {
|
|
_gestionarErrorReproduccion(e);
|
|
// Reconnect engaged: complete normally so callers do not surface a
|
|
// snackbar/dialog while the handler keeps retrying (S7-R3).
|
|
if (_reconectando) return;
|
|
}
|
|
throw Exception(_mensajeAmigable(e));
|
|
} on TimeoutException catch (e) {
|
|
_cambiandoFuente = false;
|
|
// A real network drop usually surfaces as our 12s source timeout:
|
|
// route it through the reconnect machine instead of a terminal error.
|
|
if (revision == _revisionFuente) {
|
|
_gestionarErrorReproduccion(e);
|
|
if (_reconectando) return;
|
|
}
|
|
rethrow;
|
|
} on Exception catch (e, stackTrace) {
|
|
_cambiandoFuente = false;
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] Error inesperado en playMediaItem: $e',
|
|
);
|
|
if (revision == _revisionFuente) {
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.error,
|
|
playing: false,
|
|
errorMessage: _textos.audioErrorUnexpectedPlayback,
|
|
),
|
|
);
|
|
emisoraActual = null;
|
|
this.mediaItem.add(null);
|
|
}
|
|
rethrow;
|
|
} finally {
|
|
// Leak-proof backstop. Dart runs `finally` on EVERY exit from the
|
|
// block above: normal completion, both `revision != _revisionFuente`
|
|
// early returns, every `throw`/`rethrow` out of a catch clause, and —
|
|
// crucially — any `Error` (as opposed to `Exception`) that none of the
|
|
// three clauses catches. The flag must never depend on a single
|
|
// hand-audited exit path, because a `_cambiandoFuente` stuck at `true`
|
|
// would mask a REAL stop's `idle` and leave the notification unkillable.
|
|
_cambiandoFuente = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _recrearPlayer() async {
|
|
await _estadoPlayerSub?.cancel();
|
|
await _bufferedSub?.cancel();
|
|
await _duracionSub?.cancel();
|
|
await _eventosSub?.cancel();
|
|
await _androidAudioSessionIdSub?.cancel();
|
|
|
|
final anterior = _player;
|
|
try {
|
|
await anterior.stop().timeout(_timeoutCierrePlayer);
|
|
} catch (_) {}
|
|
try {
|
|
await anterior.dispose().timeout(_timeoutCierrePlayer);
|
|
} catch (_) {}
|
|
|
|
_eq = AndroidEqualizer();
|
|
_eqDisponible = false;
|
|
_androidAudioSessionId = null;
|
|
_ultimaSessionIdEq = null;
|
|
_player = _crearPlayer();
|
|
await _player.setVolume(_volumenEfectivo);
|
|
_conectarStreamsPlayer();
|
|
}
|
|
|
|
void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) {
|
|
unawaited(
|
|
_player.play().catchError((Object error, StackTrace stackTrace) {
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] Error al iniciar ${mediaItem.title}: $error',
|
|
);
|
|
if (revision == _revisionFuente) {
|
|
_gestionarErrorReproduccion(error);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> _activarEcualizador() async {
|
|
try {
|
|
final params = await _eq.parameters;
|
|
_eqDisponible = params.bands.isNotEmpty;
|
|
if (_eqDisponible) {
|
|
await _eq.setEnabled(_ecualizadorActivo);
|
|
await aplicarPreset(_presetActual);
|
|
}
|
|
} catch (_) {
|
|
_eqDisponible = false;
|
|
}
|
|
// Item 4: an availability flip (e.g. a station switch that lands on a
|
|
// device without the native Equalizer effect) must show/hide the EQ
|
|
// custom actions immediately, not wait for a coincidental later
|
|
// player-state event.
|
|
_actualizarControlesEq();
|
|
}
|
|
|
|
/// Pure re-apply decision for a native session-id emission. No side effects.
|
|
///
|
|
/// Returns `true` when the native audio session id genuinely rotated
|
|
/// mid-playback (audio-focus ducking by another app) and the equalizer is
|
|
/// currently attached, meaning the caller should re-attach the EQ and
|
|
/// re-push the current preset's gains via [_activarEcualizador].
|
|
@visibleForTesting
|
|
static bool debeReaplicarEcualizador({
|
|
required int? sessionId,
|
|
required int? ultimaSessionIdEq,
|
|
required bool eqDisponible,
|
|
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
|
|
|
|
/// Aplica un preset al ecualizador nativo Android.
|
|
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
|
_presetActual = preset;
|
|
if (_eqDisponible) {
|
|
try {
|
|
await _eq.setEnabled(_ecualizadorActivo);
|
|
if (_ecualizadorActivo) {
|
|
final params = await _eq.parameters;
|
|
for (
|
|
int i = 0;
|
|
i < params.bands.length && i < preset.bandas.length;
|
|
i++
|
|
) {
|
|
await params.bands[i].setGain(
|
|
_mapearGananciaNativa(
|
|
preset.bandas[i],
|
|
minDecibels: params.minDecibels,
|
|
maxDecibels: params.maxDecibels,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
// Item 4: keeps the EQ custom action's preset-cycle label in sync
|
|
// regardless of WHO changed the preset (a car customAction tap or the
|
|
// phone settings screen via EstadoEcualizador) — single chokepoint.
|
|
_actualizarControlesEq();
|
|
}
|
|
|
|
/// Ajusta una banda individual.
|
|
Future<void> setBanda(int index, double db) async {
|
|
final bandas = List<double>.from(_presetActual.bandas);
|
|
if (index >= 0 && index < bandas.length) {
|
|
bandas[index] = db;
|
|
_presetActual = _presetActual.copyWithBandas(bandas);
|
|
}
|
|
if (!_eqDisponible || !_ecualizadorActivo) return;
|
|
try {
|
|
final params = await _eq.parameters;
|
|
if (index < params.bands.length) {
|
|
await params.bands[index].setGain(
|
|
_mapearGananciaNativa(
|
|
db,
|
|
minDecibels: params.minDecibels,
|
|
maxDecibels: params.maxDecibels,
|
|
),
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
double _mapearGananciaNativa(
|
|
double db, {
|
|
required double minDecibels,
|
|
required double maxDecibels,
|
|
}) {
|
|
final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0);
|
|
return minDecibels + (normalizado * (maxDecibels - minDecibels));
|
|
}
|
|
|
|
Future<void> setEcualizadorActivo(bool activo) async {
|
|
_ecualizadorActivo = activo;
|
|
if (_eqDisponible) {
|
|
try {
|
|
await _eq.setEnabled(activo);
|
|
if (activo) {
|
|
await aplicarPreset(_presetActual);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
// Item 4: keeps the EQ custom action's on/off label in sync regardless
|
|
// of WHO toggled it (a car customAction tap or the phone settings
|
|
// screen via EstadoEcualizador).
|
|
_actualizarControlesEq();
|
|
}
|
|
|
|
Future<void> setVolumen(double vol) async {
|
|
_volumen = vol.clamp(0.0, 1.0);
|
|
await _player.setVolume(_volumenEfectivo);
|
|
}
|
|
|
|
double get _volumenEfectivo =>
|
|
_atenuado ? _volumen * _factorAtenuacion : _volumen;
|
|
|
|
// ── ObjetivoAudioInterrumpible (audio-session seam, S3-R1) ───────────────
|
|
|
|
@override
|
|
bool get intencionReproducir => _intencionReproducir;
|
|
|
|
@override
|
|
bool get estaReproduciendo => playbackState.value.playing;
|
|
|
|
@override
|
|
Future<void> pausar() => pause();
|
|
|
|
@override
|
|
Future<void> reanudar() => play();
|
|
|
|
@override
|
|
Future<void> setAtenuado(bool atenuado) async {
|
|
if (_atenuado == atenuado) return;
|
|
_atenuado = atenuado;
|
|
await _player.setVolume(_volumenEfectivo);
|
|
}
|
|
|
|
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
|
|
/// existing [_activarEcualizador] (already does the correct idempotent
|
|
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
|
|
/// [_ecualizadorActivo] rather than forcing it on). Called by
|
|
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
|
|
/// interface member's doc for why the existing session-id-change trigger
|
|
/// misses this case.
|
|
@override
|
|
Future<void> reaplicarEcualizador() => _activarEcualizador();
|
|
|
|
@override
|
|
Future<void> play() {
|
|
_intencionReproducir = true;
|
|
return _player.play();
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() {
|
|
// User (or audio-session interruption) pause: disarm any pending retry —
|
|
// a stall must never fight an intentional pause (S7-R2-B, S7-R6).
|
|
_intencionReproducir = false;
|
|
_detenerReconexion();
|
|
return _player.pause();
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
// User stop (including the sleep-timer fade-out stop): cancel reconnect
|
|
// so retries never restart playback after a stop (S7-R6).
|
|
_intencionReproducir = false;
|
|
_detenerReconexion();
|
|
// Local queue (Design ADR-2): clears alongside the reconnect-cancel
|
|
// logic above — `onTaskRemoved` (which calls stop()) inherits this for
|
|
// free (Phase 3 task 3.5).
|
|
_desactivarCola();
|
|
// Genuine user stop: drop the source-change mask BEFORE `_player.stop()`,
|
|
// so its `idle` reaches `playbackState` as a REAL `idle` and
|
|
// `audio_service` tears the foreground service down as it always has.
|
|
// `stop()` never pushes `idle` itself — `BaseAudioHandler.stop()` is
|
|
// empty and the teardown is driven entirely by the player's emission —
|
|
// so a stop landing while a station change is still in flight would
|
|
// otherwise be masked to `loading` and the notification would become
|
|
// unkillable. Paired with `_revisionFuente++` below, which invalidates
|
|
// that in-flight change; its `finally` only re-clears the flag, and
|
|
// nothing re-arms it (the single `= true` assignment already ran).
|
|
_cambiandoFuente = false;
|
|
_revisionFuente++;
|
|
await _player.stop();
|
|
// Publish `idle` OURSELVES rather than trusting the player to emit it.
|
|
// `just_audio`'s `playerStateStream` is `.distinct()` over a value-equal
|
|
// `PlayerState`, so a stop landing on an already-idle player (a station
|
|
// change stopped before its native init finished pushing `loading`)
|
|
// emits NOTHING. Combined with the source-change mask above — which
|
|
// WRITES `loading` into `playbackState` rather than filtering at read
|
|
// time — that would leave the state stuck at `loading` forever:
|
|
// `audio_service` only tears the service down on a non-idle -> idle
|
|
// transition (`audio_service.dart:1131-1136`), so the notification would
|
|
// survive as an unkillable "cargando" with a dead Stop button. Strictly
|
|
// worse than the bug this branch fixes. Additive and idempotent: when
|
|
// the player DOES emit its own `idle`, this simply lands first and the
|
|
// duplicate is a no-op transition.
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.idle,
|
|
playing: false,
|
|
errorMessage: null,
|
|
),
|
|
);
|
|
emisoraActual = null;
|
|
mediaItem.add(null);
|
|
await super.stop();
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) => _player.seek(position);
|
|
|
|
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
|
|
/// 3.6). Past the last track, clears the queue and stops — mirroring
|
|
/// auto-advance's end-of-queue behavior (no wraparound).
|
|
///
|
|
/// With NO local queue this now moves to the next STATION instead of doing
|
|
/// nothing: the car's transport row offers previous/next for radio too,
|
|
/// and a button that is present but inert is worse than no button.
|
|
@override
|
|
Future<void> skipToNext() async {
|
|
// iap-freemium-unlock (Design ADR-4 backstop): station-to-station
|
|
// skipping is a browse/switch action, blocked for free tier regardless
|
|
// of queue state. Current-station play/pause/stop is untouched.
|
|
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
|
return;
|
|
}
|
|
final cola = _colaLocal;
|
|
if (cola == null) {
|
|
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false);
|
|
return;
|
|
}
|
|
final siguiente = cola.conSiguiente();
|
|
if (siguiente == null) {
|
|
_desactivarCola();
|
|
await stop();
|
|
return;
|
|
}
|
|
_colaLocal = siguiente;
|
|
await _reproducirEntradaCola(siguiente.actual);
|
|
}
|
|
|
|
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6).
|
|
/// Clamps at the first track (restarts it) instead of wrapping to the last
|
|
/// one. With no local queue, moves to the previous STATION — see
|
|
/// [skipToNext].
|
|
@override
|
|
Future<void> skipToPrevious() async {
|
|
// iap-freemium-unlock (Design ADR-4 backstop): mirrors [skipToNext].
|
|
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
|
return;
|
|
}
|
|
final cola = _colaLocal;
|
|
if (cola == null) {
|
|
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true);
|
|
return;
|
|
}
|
|
final anterior = cola.conAnterior();
|
|
_colaLocal = anterior;
|
|
await _reproducirEntradaCola(anterior.actual);
|
|
}
|
|
|
|
/// Whether what is playing right now is a RADIO STREAM, as opposed to a
|
|
/// local file.
|
|
///
|
|
/// Reported: playing a single local track and pressing next jumped to a
|
|
/// radio station. `emisoraActual` cannot answer this — `_cambiarFuente`
|
|
/// fills it in for every source, so a local MP3 arrives as an `Emisora`
|
|
/// whose `url` is its `content://` document URI. The scheme of the media
|
|
/// id is what actually distinguishes them, and it is the same test
|
|
/// `esEmisoraGrabable` uses to keep the recorder off local files.
|
|
///
|
|
/// Only a queue-less local track reaches this: folder playback sets
|
|
/// `_colaLocal` and skips within the queue, which is why the report said
|
|
/// "at least the first time" — tapping one track never builds a queue.
|
|
bool get _reproduciendoRadio {
|
|
final id = mediaItem.value?.id;
|
|
if (id == null) return false;
|
|
final esquema = Uri.tryParse(id)?.scheme.toLowerCase();
|
|
return esquema == 'http' || esquema == 'https';
|
|
}
|
|
|
|
/// Station-to-station skipping for the car's transport row.
|
|
///
|
|
/// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest
|
|
/// list the current station actually belongs to, favourites first. Anything
|
|
/// unresolvable — no source, no current station, a station that is in no
|
|
/// list, a single-entry list — leaves playback untouched. Never throws;
|
|
/// this runs from a hardware/steering-wheel button and an exception here
|
|
/// would surface as the app going silent mid-drive.
|
|
Future<void> _saltarEmisora({required bool haciaAtras}) async {
|
|
try {
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
final actual = emisoraActual;
|
|
if (fuente == null || actual == null) return;
|
|
final lista = listaParaSaltoEmisora(
|
|
actual: actual,
|
|
favoritos: await fuente.favoritos(),
|
|
misEmisoras: await fuente.misEmisoras(),
|
|
todas: await fuente.todas(),
|
|
);
|
|
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
|
|
// Reported: in the car these buttons did nothing for radio. Every early
|
|
// return here is silent, so the log has to say WHICH one fired --
|
|
// an empty list (the station matched none of the three) and a station
|
|
// that is in a list of one are indistinguishable from outside.
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras '
|
|
'actual=${actual.nombre} uuid=${actual.uuid} '
|
|
'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}',
|
|
);
|
|
if (destino == null) return;
|
|
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][ServicioAudio] Error saltando de emisora: $e');
|
|
}
|
|
}
|
|
|
|
/// Dispatches the equalizer's only custom action (decision
|
|
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
|
|
/// to the existing [setEcualizadorActivo] — the SAME entry point the
|
|
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
|
|
/// phone tap have identical effects and both refresh the action's label
|
|
/// via `_actualizarControlesEq()` (already wired into that method). The
|
|
/// preset-cycling action that used to live here is REMOVED — preset
|
|
/// selection now goes through the "Ecualizador" browsable folder (see
|
|
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
|
|
/// from [playFromMediaId] below). Any other [name] is a no-op — never
|
|
/// throws.
|
|
@override
|
|
Future<dynamic> customAction(
|
|
String name, [
|
|
Map<String, dynamic>? extras,
|
|
]) async {
|
|
switch (name) {
|
|
case accionEqToggle:
|
|
await setEcualizadorActivo(!_ecualizadorActivo);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> onTaskRemoved() async {
|
|
await stop();
|
|
await _estadoPlayerSub?.cancel();
|
|
await _bufferedSub?.cancel();
|
|
await _duracionSub?.cancel();
|
|
await _eventosSub?.cancel();
|
|
await _androidAudioSessionIdSub?.cancel();
|
|
await _player.dispose();
|
|
await _androidAudioSessionIdController.close();
|
|
for (final subject in _childrenSubjects.values) {
|
|
await subject.close();
|
|
}
|
|
// Handler teardown: release the bootstrap-owned `AudioService.asyncError`
|
|
// subscription too, so it cannot outlive the handler it was instrumenting.
|
|
// Never throws out of teardown — a failing cleanup hook must not prevent
|
|
// the rest of `onTaskRemoved` from having completed above.
|
|
try {
|
|
await _limpiezaArranqueGlobal?.call();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
|
|
// Item 3: delegates to the top-level, unit-testable function so the
|
|
// `faviconUsable` guard (never reflect the on-brand fallback artUri
|
|
// back as a real favicon) is covered without instantiating the handler.
|
|
return emisoraDesdeMediaItem(mediaItem);
|
|
}
|
|
|
|
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
|
|
// already-tested pure logic — Design "getChildren data source") ─────────
|
|
|
|
/// One-shot device-query channel (feature auto-custom-eq): the SAME
|
|
/// method channel `ServicioDispositivoAudioReal` talks to, but method
|
|
/// calls only — opening a second EventChannel subscription here would
|
|
/// steal the phone-side service's Dart stream handler.
|
|
@override
|
|
Future<List<MediaItem>> getChildren(
|
|
String parentMediaId, [
|
|
Map<String, dynamic>? options,
|
|
]) async {
|
|
try {
|
|
final constructor = ConstructorArbolAuto();
|
|
// iap-freemium-unlock (Design ADR-4): the AUTHORITATIVE entitlement
|
|
// gate, resolved ONCE per call and checked BEFORE any other
|
|
// resolution — the backstop against a stale/deep-linked non-root id
|
|
// (android-auto-media spec "Free-Tier Browse Never Leaks Real
|
|
// Content"). Never blocks the root itself (see that function's doc).
|
|
final premium = await esPremiumPersistido();
|
|
final bloqueada = respuestaBloqueadaPorEntitlement(
|
|
parentMediaId: parentMediaId,
|
|
premium: premium,
|
|
);
|
|
if (bloqueada != null) return bloqueada;
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (parentMediaId == AudioService.browsableRootId) {
|
|
final incluirMusicaLocal =
|
|
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
|
|
return constructor.raiz(
|
|
incluirMusicaLocal: incluirMusicaLocal,
|
|
premium: premium,
|
|
);
|
|
}
|
|
final musicaLocal = await hijosMusicaLocal(
|
|
parentMediaId,
|
|
fuente: fuenteLocal,
|
|
);
|
|
if (musicaLocal != null) return musicaLocal;
|
|
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
|
|
// external data source, unlike every branch below it -- checked
|
|
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
|
|
// local-music branch above is also resolved before that gate.
|
|
// The Ecualizador folder is no longer offered by `raiz()` (owner
|
|
// decision: the car keeps only the on/off toggle on the playback
|
|
// screen). This branch stays as a TRANSITIONAL courtesy: Android Auto
|
|
// caches browse trees on the head unit, so a stale "Ecualizador" entry
|
|
// can survive the update for a session or two. Answering it keeps that
|
|
// leftover working instead of opening an empty dead folder. Delete
|
|
// once no head unit can still be holding the old tree.
|
|
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
|
return itemsEcualizadorAuto(
|
|
activo: _ecualizadorActivo,
|
|
presetActual: _presetActual,
|
|
l10n: _textos,
|
|
// Read per browse, not cached: a preset saved on the phone must
|
|
// show up in the car on the next open, with no app restart.
|
|
presetsPersonalizados: await _leerPresetsPersonalizados(),
|
|
);
|
|
}
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return const [];
|
|
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
|
return constructor.carpetasFavoritos(
|
|
grupos: await fuente.grupos(),
|
|
favoritos: await fuente.favoritos(),
|
|
);
|
|
}
|
|
if (constructor.esCarpetaGrupo(parentMediaId)) {
|
|
return constructor.hijosGrupo(
|
|
parentMediaId,
|
|
favoritos: await fuente.favoritos(),
|
|
);
|
|
}
|
|
final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
|
|
return constructor.hijos(parentMediaId, emisoras: emisoras);
|
|
} catch (_) {
|
|
// Spec "Browse requested before app state is loaded": never throw out
|
|
// of a browse call, even on an unexpected failure.
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<MediaItem?> getMediaItem(String mediaId) async {
|
|
try {
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return null;
|
|
final universo = await _universoCompleto(fuente);
|
|
final constructor = ConstructorArbolAuto();
|
|
final emisora = constructor.resolver(mediaId, universo);
|
|
return emisora == null ? null : constructor.itemEmisora(emisora);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Voice search from the car ("pon Radio Clásica").
|
|
///
|
|
/// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars
|
|
/// documents as required, and it is now advertised in `systemActions` — so
|
|
/// it has to actually do something. Advertising it unimplemented would be
|
|
/// worse than omitting it: the assistant would accept the command and
|
|
/// nothing would play, with no error to explain it.
|
|
///
|
|
/// Favourites first, then my stations, then the full list, so a station the
|
|
/// driver already cares about wins a name tie. Never throws and never plays
|
|
/// something arbitrary on a miss — see [emisoraParaBusqueda].
|
|
@override
|
|
Future<void> playFromSearch(
|
|
String query, [
|
|
Map<String, dynamic>? extras,
|
|
]) async {
|
|
try {
|
|
// iap-freemium-unlock (Design ADR-4 backstop): voice search resolves a
|
|
// station and switches to it — a browse/switch action, blocked for
|
|
// free tier just like `playFromMediaId`/`skipToNext-Previous`.
|
|
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
|
return;
|
|
}
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return;
|
|
final candidatas = <Emisora>[
|
|
...await fuente.favoritos(),
|
|
...await fuente.misEmisoras(),
|
|
...await fuente.todas(),
|
|
];
|
|
final emisora = emisoraParaBusqueda(query, candidatas);
|
|
if (emisora == null) return;
|
|
await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos));
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] Error en playFromSearch($query): $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> playFromMediaId(
|
|
String mediaId, [
|
|
Map<String, dynamic>? extras,
|
|
]) async {
|
|
try {
|
|
// iap-freemium-unlock (Design ADR-4 backstop): the mandatory backstop
|
|
// against a head-unit's CACHED browse tree — `getChildren` alone
|
|
// cannot stop a stale `emisora:<uuid>`/`pista:`/`eq_preset:` tap from
|
|
// a tree fetched before a downgrade (or from another device). Checked
|
|
// BEFORE every branch below, including local tracks and the
|
|
// equalizer (android-auto-media spec "Free-Tier Browse Never Leaks
|
|
// Real Content (Authoritative Backstop)").
|
|
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
|
return;
|
|
}
|
|
// Local-track playback (Design "Local Track Playback Reuses Existing
|
|
// Pipeline", Spec "User selects a local track"): FIRST branch,
|
|
// unconditional `return` — a `pista:` id never falls through to the
|
|
// station routing below.
|
|
if (esPistaMediaId(mediaId)) {
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (fuenteLocal == null) return;
|
|
await reproducirPistaLocal(
|
|
mediaId,
|
|
fuente: fuenteLocal,
|
|
reproducir: playMediaItem,
|
|
);
|
|
return;
|
|
}
|
|
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): SECOND
|
|
// branch, after `pista:`, before the station fallthrough — mirrors the
|
|
// branch above's unconditional-return shape so neither new action id
|
|
// can fall through to station routing.
|
|
final constructorArbol = ConstructorArbolAuto();
|
|
final esAccionCarpeta =
|
|
constructorArbol.esCarpetaLocalReproducirMediaId(mediaId) ||
|
|
constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId);
|
|
if (esAccionCarpeta) {
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (fuenteLocal == null) return;
|
|
await reproducirCarpetaLocal(
|
|
mediaId,
|
|
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
|
|
fuente: fuenteLocal,
|
|
iniciarCola: _iniciarColaLocal,
|
|
);
|
|
return;
|
|
}
|
|
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
|
|
// THIRD branch, same unconditional-return shape as the two above --
|
|
// an `eq_preset:` id never falls through to station routing.
|
|
if (constructorArbol.esPresetEqMediaId(mediaId)) {
|
|
await seleccionarPresetEqPorMediaId(
|
|
mediaId,
|
|
activo: _ecualizadorActivo,
|
|
aplicarPreset: aplicarPreset,
|
|
activarEcualizador: setEcualizadorActivo,
|
|
// The SAME list `itemsEcualizadorAuto` rendered from, so a tapped
|
|
// custom preset resolves instead of silently doing nothing.
|
|
presets: presetsEcualizadorAuto(
|
|
personalizados: await _leerPresetsPersonalizados(),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return;
|
|
await reproducirPorMediaId(
|
|
mediaId,
|
|
fuente: fuente,
|
|
reproducir: playMediaItem,
|
|
);
|
|
} catch (e) {
|
|
// Spec "Unknown or stale media id": never propagate from the handler.
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudio] Error en playFromMediaId($mediaId): $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<Emisora>> _listaParaCarpeta(
|
|
FuenteEmisorasAuto fuente,
|
|
String parentId,
|
|
) => switch (parentId) {
|
|
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
|
|
ConstructorArbolAuto.idTodas => fuente.todas(),
|
|
_ => Future.value(const []),
|
|
};
|
|
|
|
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
|
|
final listas = await Future.wait([
|
|
fuente.favoritos(),
|
|
fuente.misEmisoras(),
|
|
fuente.todas(),
|
|
]);
|
|
return listas.expand((lista) => lista).toList();
|
|
}
|
|
}
|