Files
pluriwave/lib/estado/estado_radio.dart
T
FreeTLab 8fc3d99fbd fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:08:09 +02:00

1064 lines
41 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show Locale;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import 'estado_busqueda.dart';
import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/navegacion_auto.dart';
import '../servicios/persistencia_tolerante.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart';
import '../servicios/servicio_grabacion_radio.dart';
import '../servicios/servicio_radio.dart';
import '../servicios/servicio_timer.dart';
export 'orden_emisoras.dart' show OrdenEmisoras;
/// Estado global de la app con ChangeNotifier (Provider).
///
/// S4 end-state: playback + stations + favorites orchestration. EQ, recording
/// and search state live in their own notifiers (EstadoEcualizador,
/// EstadoGrabacion, EstadoBusqueda) created here during the S4 transition and
/// exposed app-wide through ListenableProviders in app.dart.
class EstadoRadio extends ChangeNotifier {
EstadoRadio({
ServicioAudio? audio,
ServicioFavoritos? favoritos,
ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador,
ServicioDispositivoAudio? dispositivoAudio,
ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom,
FuenteEmisorasAuto? fuenteAuto,
bool iniciarAutomaticamente = true,
// iap-freemium-unlock (Design ADR-3): threaded straight through to the
// internal `EstadoGrabacion` below — `EstadoRadio` itself has no gated
// behavior of its own, but it owns that notifier's construction, so it
// inherits the same "required, never defaulted" entitlement contract.
required bool Function() esPremium,
}) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(),
radio = radio ?? ServicioRadio(),
servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom,
_fuenteAuto = fuenteAuto {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
dispositivoAudio: _dispositivoAudio,
emisoraActualUuid: () => emisoraActual?.uuid,
);
grabacion = EstadoGrabacion(
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
emisoraActual: () => emisoraActual,
alError: _errorController.add,
esPremium: esPremium,
);
busqueda = EstadoBusqueda(
radio: this.radio,
ordenListas: () => _ordenListas,
textos: () => _textos,
alError: _errorController.add,
);
timer = ServicioTimer(this.audio);
_escucharErroresReproduccion();
if (iniciarAutomaticamente) {
_initFuture = _init();
}
}
final ServicioAudio audio;
final ServicioFavoritos favoritos;
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
/// Optional device service — null means no device subscription established.
/// Wired in production from app.dart; defaults to null in tests.
final ServicioDispositivoAudio? _dispositivoAudio;
/// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart.
late final EstadoEcualizador ecualizador;
late final EstadoGrabacion grabacion;
late final EstadoBusqueda busqueda;
static const ServicioExportImport _exportImport = ServicioExportImport();
final SharedPreferences? _prefs;
final Future<File> Function()? _resolverArchivoCustom;
/// Android Auto browse source (Design "live snapshot the source
/// prefers"). Optional and unused by default — wired from main.dart via
/// the [FuenteEmisorasAutoLocal] instance registered into the handler.
/// When set, this instance's in-memory lists are pushed on every mutation
/// so the car sees the same data as the phone without a duplicate read.
final FuenteEmisorasAuto? _fuenteAuto;
/// Single startup instance injected from main() (S3-R4); falls back to
/// getInstance() only when nothing was injected (tests, legacy callers).
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
audio.configurarLocalizaciones(l10n);
grabacion.configurarLocalizaciones(l10n);
// The alarm bridge gets its localizations through
// EstadoAlarmas.configurarLocalizaciones (Decision 3.2) — the old
// static ServicioAlarmasAndroid shim is gone.
}
late final ServicioTimer timer;
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
Future<void>? _initFuture;
int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada;
String? _emisoraPreferidaUuid;
AppLocalizations? _l10n;
// Errores de reproducción → SnackBar.
final _errorController = StreamController<String>.broadcast();
Stream<String> get errorStream => _errorController.stream;
List<Emisora> _populares = [];
List<Emisora> _tendencias = [];
List<Emisora> _listaFavoritos = [];
List<GrupoFavoritos> _gruposFavoritos = [];
List<Emisora> _emisorasCustom = [];
// persistence-resilience (D5): set when the custom-stations file EXISTS
// but could not be READ at the OS level (vs. a parse failure, which
// quarantines the file instead -- see _ponerEnCuarentena). While true,
// _guardarEmisorasCustom is suppressed for the rest of this session: we
// cannot tell whether the underlying file is actually intact, so we never
// risk clobbering it with an empty in-memory list. Unlike the Alarms
// degraded flag, this is intentionally NOT cleared by an explicit
// add/remove -- only the NEXT clean/partial load in _cargarEmisorasCustom
// clears it (D5's asymmetry: a transiently-unreadable file may still be
// intact on disk).
bool _customDegradado = false;
bool _cargandoPopulares = false;
String? _errorCarga;
// Identity-memoized derived lists so `context.select` consumers only
// rebuild when the underlying data actually changes (S4-R5).
final _memoPopulares = MemoLista<Emisora>();
final _memoTendencias = MemoLista<Emisora>();
final _memoFavoritos = MemoLista<Emisora>();
final _memoFavoritosManual = MemoLista<Emisora>();
final _memoGrupos = MemoLista<GrupoFavoritos>();
final _memoCustom = MemoLista<Emisora>();
final _memoInicio = MemoLista<Emisora>();
final _memoDisponibles = MemoLista<Emisora>();
final _memoTimerPresets = MemoLista<int>();
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
// keeps showing "what I was listening to" (stopped, not playing) after a
// full app restart instead of going empty.
static const _keyUltimaEmisora = 'ultima_emisora_v1';
static const _timerSuenoPresetsDefecto = <int>[
180,
300,
600,
900,
1800,
3600,
5400,
7200,
10800,
];
List<int> _timerSuenoPresetsSegundos = List<int>.from(
_timerSuenoPresetsDefecto,
);
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
List<Emisora> get populares => _memoPopulares.obtener([
_populares,
_ordenListas,
], () => ordenarEmisoras(_populares, _ordenListas));
List<Emisora> get tendencias => _memoTendencias.obtener([
_tendencias,
_ordenListas,
], () => ordenarEmisoras(_tendencias, _ordenListas));
List<Emisora> get listaFavoritos => _memoFavoritos.obtener([
_listaFavoritos,
_ordenListas,
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
/// WU4, `favorites-organization` spec: Favoritos' own manual order —
/// unlike [listaFavoritos], this is NOT re-sorted by the global
/// [ordenListas] setting on every read. It reflects exactly the order
/// stored via the `orden` column (`ServicioFavoritos.obtenerTodos()`),
/// which [reordenarFavorito] and [ordenarFavoritos] mutate. Other
/// consumers of `listaFavoritos` (Android Auto, the Escuchar grid) are
/// unaffected by drag-reordering Favoritos.
List<Emisora> get listaFavoritosManual => _memoFavoritosManual.obtener([
_listaFavoritos,
], () => List<Emisora>.unmodifiable(_listaFavoritos));
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
_gruposFavoritos,
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
List<Emisora> get emisorasCustom => _memoCustom.obtener([
_emisorasCustom,
_ordenListas,
], () => ordenarEmisoras(_emisorasCustom, _ordenListas));
bool get cargandoPopulares => _cargandoPopulares;
String? get error => _errorCarga;
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
OrdenEmisoras get ordenListas => _ordenListas;
List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
_timerSuenoPresetsSegundos,
], () => List<int>.unmodifiable(_timerSuenoPresetsSegundos));
bool get emisoraActualEsFavorita {
final actual = emisoraActual;
if (actual == null) return false;
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
}
/// Lista principal (home): custom + populares, sin duplicados.
List<Emisora> get emisorasInicio =>
_memoInicio.obtener([_emisorasCustom, _populares], () {
final mapa = <String, Emisora>{};
for (final emisora in _emisorasCustom) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
});
List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
[
_listaFavoritos,
_emisorasCustom,
_populares,
_tendencias,
busqueda.resultados,
busqueda.cercanas,
],
() {
final mapa = <String, Emisora>{};
for (final emisora in _listaFavoritos) {
mapa[emisora.uuid] = emisora;
}
for (final emisora in _emisorasCustom) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _populares) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in _tendencias) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in busqueda.resultados) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
for (final emisora in busqueda.cercanas) {
mapa.putIfAbsent(emisora.uuid, () => emisora);
}
return mapa.values.toList();
},
);
Future<void> inicializar() {
_initFuture ??= _init();
return _initFuture!;
}
Future<void> _init() async {
await grabacion.inicializar();
await ecualizador.cargarPersistido();
await _cargarOrdenListas();
await _cargarEmisoraPreferida();
await _cargarTimerSuenoPresets();
await Future.wait([
cargarPopulares(),
cargarFavoritos(),
cargarGruposFavoritos(),
_cargarEmisorasCustom(),
]);
await _normalizarEmisoraPreferida();
await _restaurarUltimaEmisora();
}
/// Issue 4 (feedback-pruebas): restores the last-played station as a
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
/// something is ALREADY selected (a real play already ran concurrently),
/// this is a no-op. Never touches `audio`: no playback starts, no network
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
/// "detenido" default, exactly like every other consumer of
/// `emisoraActual` already expects (they gate "is it playing" on the
/// separate playback-status stream, never on `emisoraActual != null`).
Future<void> _restaurarUltimaEmisora() async {
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyUltimaEmisora);
if (raw == null) return;
final mapa = jsonDecode(raw) as Map<String, dynamic>;
_emisoraSeleccionada = Emisora.fromMap(mapa);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'restaurar',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.error && timer.activo) {
unawaited(timer.cancelar());
}
if ((estado == EstadoReproduccion.detenido ||
estado == EstadoReproduccion.pausado ||
estado == EstadoReproduccion.error) &&
grabacion.activa) {
unawaited(grabacion.detener());
}
// Design "playback coherence with EstadoRadio": a car-initiated
// selection (Android Auto's playFromMediaId) changes audio.emisoraActual
// directly, bypassing reproducir(). Without this, _emisoraSeleccionada
// would keep shadowing the car's station on emisoraActual's getter.
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4's write used to live here as well. It is gone: the handler
// persists every station itself from `_cambiarFuente`, which is the
// same source change that moved `audio.emisoraActual` and is the
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
}
notifyListeners();
});
}
Future<void> cargarPopulares() async {
_cargandoPopulares = true;
_errorCarga = null;
notifyListeners();
try {
final results = await Future.wait([
radio.obtenerPopulares(limit: 30),
radio.obtenerTendencias(limit: 20),
]);
_populares = results[0];
_tendencias = results[1];
} catch (_) {
_errorCarga = _textos.radioApiConnectionError;
} finally {
_cargandoPopulares = false;
// Design "live snapshot the source prefers": Android Auto's `Todas`
// folder mirrors the same populares list the phone just loaded.
//
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
// the raw [_populares] field — the same [_ordenListas] setting the
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
// sort by must also govern this folder's order, not the API's raw
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
// re-sorts, so whatever order arrives here IS what the driver sees.
_fuenteAuto?.actualizarSnapshot(todas: populares);
notifyListeners();
}
}
Future<void> cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida();
// Fix `android-auto-orden`: pushes the documented manual-order accessor
// explicitly. [listaFavoritosManual] is backed by the same list as
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
// manual order), but naming the intent here — "the exact order the
// Favoritos screen shows and reorders" — keeps this call from silently
// drifting onto a re-sorted list in a future refactor.
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
notifyListeners();
}
Future<void> cargarGruposFavoritos() async {
_gruposFavoritos = await favoritos.obtenerGrupos();
// Design "live snapshot the source prefers": Android Auto's grouped
// Favoritos tree mirrors the same group list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos);
notifyListeners();
}
Future<void> crearGrupoFavoritos(String nombre) async {
await favoritos.crearGrupo(nombre);
await cargarGruposFavoritos();
}
Future<void> renombrarGrupoFavoritos(String id, String nombre) async {
await favoritos.renombrarGrupo(id, nombre);
await cargarGruposFavoritos();
}
Future<void> eliminarGrupoFavoritos(String id) async {
await favoritos.eliminarGrupo(id);
await Future.wait([cargarFavoritos(), cargarGruposFavoritos()]);
}
Future<void> asignarGrupoFavorito(String uuid, String grupoId) async {
await favoritos.asignarGrupo(uuid, grupoId);
await cargarFavoritos();
}
/// WU4: persists a single drag-to-reorder move within [listaFavoritosManual].
/// [nuevoIndice] is the absolute target position among ALL favorites (not
/// scoped to any chip filter) — the screen translates a filtered-list drag
/// into this global index before calling this method.
Future<void> reordenarFavorito(String uuid, int nuevoIndice) async {
await favoritos.reordenar(uuid, nuevoIndice);
await cargarFavoritos();
}
/// WU4: the Favoritos `swap_vert` sort action. Reuses the existing
/// [OrdenEmisoras] criteria and [ordenarEmisoras] function — "It MUST NOT
/// introduce a sort criterion with no backing implementation"
/// (favorites-organization spec). Unlike [ordenListas] (a GLOBAL setting
/// affecting favorites/searches/nearby/quick-lists), this sorts ONLY the
/// favorites list once and PERSISTS the result as the new manual order —
/// consistent with [reordenarFavorito]'s "the order MUST persist" contract.
Future<void> ordenarFavoritos(OrdenEmisoras criterio) async {
final ordenados = ordenarEmisoras(_listaFavoritos, criterio);
for (var i = 0; i < ordenados.length; i++) {
await favoritos.reordenar(ordenados[i].uuid, i);
}
await cargarFavoritos();
}
Future<void> cambiarEmisoraPreferida(Emisora? emisora) async {
_emisoraPreferidaUuid = emisora?.uuid;
final prefs = await _resolverPrefs();
if (_emisoraPreferidaUuid == null) {
await prefs.remove(_keyEmisoraPreferida);
} else {
await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!);
}
notifyListeners();
}
Future<void> reproducirEmisoraPreferida() async {
final preferida = emisoraPreferida;
if (preferida == null) return;
await reproducir(preferida);
}
Future<void> _cargarTimerSuenoPresets() async {
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyTimerSuenoPresets);
if (raw == null) return;
final decoded = jsonDecode(raw);
if (decoded is! List) return;
final presets =
decoded
.whereType<num>()
.map((n) => n.toInt())
.where((s) => s > 0)
.toSet()
.toList()
..sort();
if (presets.isNotEmpty) {
_timerSuenoPresetsSegundos = presets.take(12).toList();
}
} catch (_) {
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
}
}
Future<void> _cargarEmisoraPreferida() async {
final prefs = await _resolverPrefs();
_emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida);
}
Future<void> _cargarOrdenListas() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyOrdenListas);
_ordenListas = switch (raw) {
'nombre' => OrdenEmisoras.nombre,
'calidad' => OrdenEmisoras.calidad,
'popularidad' => OrdenEmisoras.popularidad,
_ => OrdenEmisoras.calidad,
};
}
Future<void> cambiarOrdenListas(OrdenEmisoras orden) async {
_ordenListas = orden;
final prefs = await _resolverPrefs();
await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden();
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
// derived from this same setting (see cargarPopulares/
// _cargarEmisorasCustom above) — without an immediate re-push, a live
// car session would keep showing the OLD order until the next full
// reload instead of updating right away, same as the phone does via
// this method's own memoized getters.
_fuenteAuto?.actualizarSnapshot(
todas: populares,
misEmisoras: emisorasCustom,
);
notifyListeners();
}
Future<void> _normalizarEmisoraPreferida() async {
final preferida = _resolverEmisoraPreferida();
if (preferida?.uuid == _emisoraPreferidaUuid) return;
_emisoraPreferidaUuid = preferida?.uuid;
final prefs = await _resolverPrefs();
if (_emisoraPreferidaUuid == null) {
await prefs.remove(_keyEmisoraPreferida);
} else {
await prefs.setString(_keyEmisoraPreferida, _emisoraPreferidaUuid!);
}
}
Emisora? _resolverEmisoraPreferida() {
final uuid = _emisoraPreferidaUuid;
if (uuid != null) {
for (final emisora in _listaFavoritos) {
if (emisora.uuid == uuid) return emisora;
}
}
if (_listaFavoritos.isNotEmpty) return _listaFavoritos.first;
if (uuid != null) {
for (final emisora in emisorasDisponiblesPreferencia) {
if (emisora.uuid == uuid) return emisora;
}
}
final disponibles = emisorasDisponiblesPreferencia;
return disponibles.isEmpty ? null : disponibles.first;
}
Future<void> reproducir(Emisora emisora) async {
final revision = ++_revisionReproduccion;
if (grabacion.activa) {
await grabacion.detener();
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below reaches for this very station — see
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
unawaited(radio.registrarClick(emisora.uuid));
await ecualizador.aplicarPresetActivo(
ecualizador.presetParaEmisora(emisora.uuid),
);
if (revision != _revisionReproduccion) return;
notifyListeners();
} catch (e) {
if (revision != _revisionReproduccion) return;
if (timer.activo) {
unawaited(timer.cancelar());
}
final mensajeError = e.toString().replaceFirst('Exception: ', '');
_emisoraSeleccionada = audio.emisoraActual;
_errorController.add(
mensajeError.isNotEmpty && mensajeError != 'Exception'
? mensajeError
: _textos.radioCannotPlayStation(
localizedStationName(_textos, emisora.nombre),
),
);
notifyListeners();
}
}
Future<void> detenerReproduccion() async {
if (grabacion.activa) {
await grabacion.detener();
}
await audio.detener();
notifyListeners();
}
Future<void> togglePlay() async {
if (audio.estaSonando && grabacion.activa) {
await grabacion.detener();
}
await audio.togglePlay();
notifyListeners();
}
Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) {
await ecualizador.deshabilitarPresetPorEmisora(
emisora.uuid,
notificar: false,
);
}
await cargarFavoritos();
return esFav;
}
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
// ── Emisoras personalizadas ───────────────────────────────────────────────
Future<File> _archivoCustom() async {
if (_resolverArchivoCustom != null) {
return _resolverArchivoCustom();
}
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/emisoras_custom.json');
}
/// Loads the custom-stations file with per-entry tolerance
/// (persistence-resilience D1/D5). Two DIFFERENT failure kinds get TWO
/// different treatments because they carry different guarantees about
/// whether the file itself is still intact:
/// - the file cannot be READ at the OS level (see [_leerContenidoCustom])
/// -> IO-fail: unknown whether the file is intact, so it is left
/// completely untouched and [_customDegradado] suppresses writes.
/// - the file reads fine but its top-level JSON/shape is invalid -> the
/// bytes we DID manage to read are definitely the corrupt culprit, so
/// they are quarantined into a `.corrupt` sidecar and the live path is
/// cleared for the next write ([_ponerEnCuarentena]); no flag needed,
/// the cleared path is itself the "authority restored" signal.
/// - per-entry failures inside an otherwise-valid list are handled by the
/// shared [parseListaTolerante] (D1): survivors are kept, no flag/
/// quarantine at all.
Future<void> _cargarEmisorasCustom() async {
// Resolving the path itself is part of the IO surface: a throw here must
// get the same IO-fail treatment as an unreadable file, not escape into
// _init()'s Future.wait and take the sibling loads down with it.
final File archivo;
try {
archivo = await _archivoCustom();
} catch (e) {
_customDegradado = true;
registrarSaltoPersistencia(
subsistema: 'emisoras_custom',
detalle: 'resolucion de ruta',
razon: e.toString(),
);
// Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
// (see the doc on this method's other 3 identical call sites below).
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return;
}
final contenido = await _leerContenidoCustom(archivo);
if (contenido == null) return; // ya resuelto: vacio o degradado por IO.
try {
final data = jsonDecode(contenido) as List;
final resultado = parseListaTolerante<Emisora>(
data,
Emisora.fromMap,
subsistema: 'emisoras_custom',
coleccion: 'emisoras_custom',
);
_emisorasCustom = resultado.validas;
_customDegradado = false;
} catch (e) {
await _ponerEnCuarentena(archivo);
_emisorasCustom = [];
registrarSaltoPersistencia(
subsistema: 'emisoras_custom',
detalle: archivo.path,
razon: e.toString(),
);
}
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
}
/// Reads the raw content of [archivo]; returns null when the outcome was
/// already fully resolved here, so the caller has nothing left to parse:
/// - the file does not exist -> healthy empty state (unchanged behavior).
/// - `exists()`/`readAsString()` throws -> IO-fail (D5): the file is left
/// untouched (we cannot know if it is actually intact) and
/// [_customDegradado] suppresses [_guardarEmisorasCustom] for the rest
/// of this session.
Future<String?> _leerContenidoCustom(File archivo) async {
try {
if (!await archivo.exists()) {
_emisorasCustom = [];
_customDegradado = false;
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
return await archivo.readAsString();
} catch (e) {
_emisorasCustom = [];
_customDegradado = true;
registrarSaltoPersistencia(
subsistema: 'emisoras_custom',
detalle: archivo.path,
razon: e.toString(),
);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
}
/// Moves an unparseable custom-stations file out of the live path so the
/// next add/remove starts fresh. If a `.corrupt` sidecar from a PREVIOUS
/// quarantine already exists, that earlier payload is preserved untouched
/// and the newly-corrupt live file is simply dropped (D5) -- a sidecar
/// only ever holds the OLDEST unresolved quarantine, never overwritten by
/// a newer one.
Future<void> _ponerEnCuarentena(File archivo) async {
final sidecar = File('${archivo.path}.corrupt');
if (await sidecar.exists()) {
await archivo.delete();
} else {
await archivo.rename(sidecar.path);
}
}
Future<void> _guardarEmisorasCustom() async {
// persistence-resilience (D5): never write while an IO-degraded read
// means we cannot be sure the on-disk file is still intact.
if (_customDegradado) return;
final archivo = await _archivoCustom();
await archivo.writeAsString(
jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()),
);
}
Future<void> agregarEmisoraCustom(Emisora emisora) async {
// Reassign (not mutate) so identity-memoized views refresh (S4-R5).
_emisorasCustom = [
..._emisorasCustom.where((e) => e.uuid != emisora.uuid),
emisora,
];
await _guardarEmisorasCustom();
notifyListeners();
}
// Compatibilidad con el nombre histórico (typo original).
Future<void> agregarEmitoraCustom(Emisora emisora) =>
agregarEmisoraCustom(emisora);
Future<void> eliminarEmisoraCustom(String uuid) async {
_emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
await _guardarEmisorasCustom();
notifyListeners();
}
// Compatibilidad con el nombre histórico (typo original).
Future<void> eliminarEmitoraCustom(String uuid) =>
eliminarEmisoraCustom(uuid);
// ── Export / Import ───────────────────────────────────────────────────────
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v4 — portabilidad completa
/// con presets por dispositivo, matriz multi-device y el toggle
/// on/off del ecualizador).
/// La forma del sobre vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
final prefs = await _resolverPrefs();
// Alarmas: leemos el JSON crudo de SharedPreferences para no duplicar
// lógica de ServicioAlarmas y evitar inyectar una dependencia nueva.
final alarmasRaw = prefs.getString(_keyAlarmasConfig);
final alarmasData =
alarmasRaw != null
? jsonDecode(alarmasRaw) as Map<String, dynamic>
: null;
return _exportImport.construirExportacion(
gruposFavoritos: grupos,
favoritos: favs,
emisorasCustom: _emisorasCustom,
presetPrincipal: ecualizador.presetPrincipal,
presetsPorEmisora: ecualizador.presetsPorEmisora,
alarmas: alarmasData,
emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
// v3 extensions — multi-device EQ fields.
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
// v4 extension — equalizer global on/off toggle.
ecualizadorActivo: ecualizador.activo,
);
}
/// Exportación lista para compartir como archivo (JSON con indentación).
Future<String> exportarConfigJson() async =>
_exportImport.exportar(await exportarConfig());
/// Parsea un backup JSON; null cuando el contenido no es válido (S4-R4).
Map<String, dynamic>? parsearConfigJson(String raw) =>
_exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// v3 (+ presets por dispositivo, presets matriz, toggle multi-device)
/// y v4 (+ toggle on/off del ecualizador).
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version > 4) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs();
// ── Grupos de favoritos (v2) ──────────────────────────────────────────
// Restauramos primero para que al agregar favoritos ya existan los grupos.
if (version >= 2) {
final gruposRaw = data['gruposFavoritos'] as List? ?? [];
for (final raw in gruposRaw) {
final g = GrupoFavoritos.fromMap(Map<String, dynamic>.from(raw as Map));
// Usamos insert directo para preservar id, orden y nombre originales.
await favoritos.restaurarGrupo(g);
}
await cargarGruposFavoritos();
}
// ── Favoritos ─────────────────────────────────────────────────────────
final favRaw = data['favoritos'] as List? ?? [];
for (final raw in favRaw) {
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
// los grupos restaurados arriba volvían como cascarones vacíos y todas
// las emisoras aterrizaban en «Sin asignar».
await favoritos.restaurarFavorito(emisora);
}
// ── Emisoras custom ───────────────────────────────────────────────────
final customRaw = data['emisorasCustom'] as List? ?? [];
_emisorasCustom =
customRaw
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
await _guardarEmisorasCustom();
// ── Ecualizador ───────────────────────────────────────────────────────
final principalRaw = data['presetPrincipalEcualizador'];
final presetPrincipal =
principalRaw is Map
? PresetEcualizador.desdeJson(
Map<String, dynamic>.from(principalRaw),
)
: PresetEcualizador.flat;
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
final presetsPorEmisora = presetsRaw.map<String, PresetEcualizador>(
(uuid, presetJson) => MapEntry(
uuid as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
// v4 extension: equalizer on/off toggle. Read unconditionally — the key
// is simply absent on any pre-v4 backup, which resolves to `null` and
// leaves the user's CURRENT toggle untouched (see
// `EstadoEcualizador.importarConfiguracion` doc): an old backup must
// never flip a live setting it never carried.
final ecualizadorActivo = data['ecualizadorActivo'] as bool?;
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
activo: ecualizadorActivo,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
if (version >= 2) {
final alarmasData = data['alarmas'];
if (alarmasData is Map<String, dynamic>) {
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
// EstadoAlarmas es un ChangeNotifier independiente y de larga vida
// que ya cargó sus alarmas en memoria: NO relee este storage por sí
// solo. El llamador (pantalla_ajustes_backup.dart) es responsable de
// invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido
// de `refrescarProgramacion()` tras un import exitoso; EstadoRadio
// se mantiene deliberadamente sin depender de EstadoAlarmas.
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
}
}
// ── Preferencias de usuario (v2) ──────────────────────────────────────
if (version >= 2) {
final preferidaUuid = data['emisoraPreferidaUuid'] as String?;
_emisoraPreferidaUuid = preferidaUuid;
if (preferidaUuid == null) {
await prefs.remove(_keyEmisoraPreferida);
} else {
await prefs.setString(_keyEmisoraPreferida, preferidaUuid);
}
final ordenRaw = data['ordenListas'] as String?;
_ordenListas = switch (ordenRaw) {
'nombre' => OrdenEmisoras.nombre,
'calidad' => OrdenEmisoras.calidad,
'popularidad' => OrdenEmisoras.popularidad,
_ => OrdenEmisoras.calidad,
};
await prefs.setString(_keyOrdenListas, _ordenListas.name);
final timerPresetsRaw = data['timerSuenoPresetsSegundos'] as List?;
if (timerPresetsRaw != null) {
await guardarTimerSuenoPresetsSegundos(
timerPresetsRaw.whereType<num>().map((n) => n.toInt()).toList(),
);
}
}
await cargarFavoritos();
notifyListeners();
}
// ── Timer ─────────────────────────────────────────────────────────────────
void iniciarTimer(int minutos) {
timer.iniciar(minutos);
notifyListeners();
}
void iniciarTimerDuracion(Duration duracion) {
timer.iniciarDuracion(duracion);
notifyListeners();
}
void cancelarTimer() {
unawaited(timer.cancelar());
notifyListeners();
}
Future<void> guardarTimerSuenoPresetsSegundos(List<int> segundos) async {
final normalizados =
segundos
.where((s) => s > 0)
.map((s) => s.clamp(1, const Duration(hours: 23).inSeconds))
.toSet()
.toList()
..sort();
_timerSuenoPresetsSegundos =
normalizados.isEmpty
? List<int>.from(_timerSuenoPresetsDefecto)
: normalizados.take(12).toList();
final prefs = await _resolverPrefs();
await prefs.setString(
_keyTimerSuenoPresets,
jsonEncode(_timerSuenoPresetsSegundos),
);
notifyListeners();
}
Future<void> agregarTimerSuenoPreset(Duration duracion) async {
await guardarTimerSuenoPresetsSegundos([
..._timerSuenoPresetsSegundos,
duracion.inSeconds,
]);
}
Future<void> eliminarTimerSuenoPreset(int segundos) async {
await guardarTimerSuenoPresetsSegundos(
_timerSuenoPresetsSegundos.where((s) => s != segundos).toList(),
);
}
Future<void> restaurarTimerSuenoPresets() async {
_timerSuenoPresetsSegundos = List<int>.from(_timerSuenoPresetsDefecto);
final prefs = await _resolverPrefs();
await prefs.remove(_keyTimerSuenoPresets);
notifyListeners();
}
@override
void dispose() {
_suscripcionEstadoAudio?.cancel();
_errorController.close();
ecualizador.dispose();
busqueda.dispose();
grabacion.dispose();
audio.dispose();
timer.dispose();
_dispositivoAudio?.dispose();
super.dispose();
}
}