diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index e4d0014..0509862 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -7,15 +7,11 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path_provider/path_provider.dart'; import '../estado/orden_emisoras.dart'; -import '../modelos/dispositivo_audio.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; -import '../modelos/preset_ecualizador.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; -import 'servicio_dispositivo_audio.dart'; -import 'servicio_ecualizador.dart'; import 'servicio_favoritos.dart'; /// Generic page slice over [items] (Design ADR-6): returns at most [tamano] @@ -36,53 +32,10 @@ bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) = const _prefijoEmisora = 'emisora:'; -/// EQ preset media-id prefix (Design ADR-1), collision-free against -/// [_prefijoEmisora], `grupo:` and the bare folder id constants. -const _prefijoPresetEq = 'eq_preset:'; - -/// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare -/// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name -/// case is rejected downstream by [resolverPresetEq], not by this routing -/// predicate. -bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq); - -/// Custom-EQ band folder media-id prefix (feature auto-custom-eq): -/// `eq_banda:`. Collision-free against [_prefijoPresetEq] and -/// [_prefijoGananciaEq] — the three `eq_` prefixes diverge at index 3 -/// (`b` vs `p` vs `g`), so no `startsWith` check ever matches a sibling's id. -const _prefijoBandaEq = 'eq_banda:'; - -/// Whether [id] identifies a custom-EQ band folder (feature auto-custom-eq). -bool esBandaEqMediaId(String id) => id.startsWith(_prefijoBandaEq); - -/// Builds the `eq_banda:` media id for band [indice] (feature -/// auto-custom-eq) — single authority for the id shape, shared by the tree -/// builder and the handler's children-changed notification. -String idBandaEq(int indice) => '$_prefijoBandaEq$indice'; - -/// Custom-EQ gain leaf media-id prefix (feature auto-custom-eq): -/// `eq_gain::`. Collision-free against [_prefijoBandaEq] and -/// [_prefijoPresetEq] (see [_prefijoBandaEq]'s divergence note). -const _prefijoGananciaEq = 'eq_gain:'; - -/// Whether [id] identifies a custom-EQ gain leaf item (feature -/// auto-custom-eq). -bool esGananciaEqMediaId(String id) => id.startsWith(_prefijoGananciaEq); - -/// Number of fixed EQ bands (`PresetEcualizador` asserts exactly 5). -const _numBandasEq = 5; - -/// Frequency labels for the 5 EQ bands, in the same order as -/// `PresetEcualizador.bandas` (60Hz, 250Hz, 1kHz, 4kHz, 16kHz — the model's -/// documented band layout, same frequencies the phone's -/// `EcualizadorWidget._etiquetas` renders). -const etiquetasBandasEq = ['60 Hz', '250 Hz', '1 kHz', '4 kHz', '16 kHz']; - /// Local-track media-id prefix (Design "media-id scheme"), collision-free -/// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare -/// folder id constants. Top-level (not a [ConstructorArbolAuto] member), -/// mirroring [_prefijoPresetEq]/[esPresetMediaId]'s shape — used directly -/// from `playFromMediaId`'s dispatch in `servicio_audio.dart`. +/// against [_prefijoEmisora], `grupo:` and the bare folder id constants. +/// Top-level (not a [ConstructorArbolAuto] member) — used directly from +/// `playFromMediaId`'s dispatch in `servicio_audio.dart`. const _prefijoPista = 'pista:'; /// Whether [id] identifies a local-track playable leaf item (Design @@ -237,17 +190,6 @@ class ConstructorArbolAuto { static const idTodas = 'todas'; static const idMisEmisoras = 'mis_emisoras'; - /// Root folder id for the EQ presets folder (Design "media-id scheme"). - /// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated - /// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the - /// generic station-list `hijos()` path. - static const idEcualizador = 'ecualizador'; - - /// Browsable "Personalizado" folder id under [idEcualizador] (feature - /// auto-custom-eq). Deliberately NOT added to [_idsCarpetas] — routed by - /// its own dedicated branch in `getChildren`, like [idEcualizador] itself. - static const idEqPersonalizado = 'eq_custom'; - /// Root folder id for the local-music browsable root (Design "media-id /// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own /// dedicated branch (`hijosMusicaLocal`), not the generic station-list @@ -338,21 +280,22 @@ class ConstructorArbolAuto { }; /// The root folders (Favoritos, Todas las emisoras, Mis emisoras, - /// optionally Música Local, Ecualizador), all non-playable. `Ecualizador` - /// is deliberately LAST (Design ADR-2): content-browsing folders are the - /// primary car task and stay first, the EQ tool trails them. `Música - /// Local` is OMITTED entirely (not just empty) unless [incluirMusicaLocal] - /// is `true` (Design "Local root hidden until a folder is configured") — - /// the caller passes `fuente.hayCarpetaConfigurada()`, keeping this - /// builder itself synchronous and side-effect free. When `false`, the - /// result is byte-identical to the pre-local-music 4-folder tree - /// (regression guard). + /// optionally Música Local), all non-playable. + /// + /// There is deliberately no equalizer folder: EQ is configured on the phone + /// only. The car still gets the right sound, because the per-device preset + /// is applied automatically when the output device changes — that lives in + /// `EstadoEcualizador`, not in this tree. + /// + /// `Música Local` is OMITTED entirely (not just empty) unless + /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder + /// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`, + /// keeping this builder itself synchronous and side-effect free. List raiz({required bool incluirMusicaLocal}) => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), - _carpeta(idEcualizador, 'Ecualizador'), ]; MediaItem _carpeta(String id, String titulo) => MediaItem( @@ -762,66 +705,6 @@ class ConstructorArbolAuto { ); } - /// Maps a [PresetEcualizador] to a playable `MediaItem` with id - /// `eq_preset:` (Design ADR-1). - MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem( - id: '$_prefijoPresetEq${preset.nombre}', - title: preset.nombre, - playable: true, - extras: _contentStyleGrid, - ); - - /// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec - /// "Car requests the Ecualizador folder"). - List presetsEq(List presets) => - presets.map(itemPresetEq).toList(); - - /// The browsable "Personalizado" folder appended after the fixed preset - /// leaves under `Ecualizador` (feature auto-custom-eq). Hardcoded Spanish - /// label, matching every other car-tree label in this file — never routed - /// through `AppLocalizations` (see [_tituloMasLocal]'s precedent). - MediaItem itemEqPersonalizado() => - _carpeta(idEqPersonalizado, 'Personalizado'); - - /// The 5 per-band browsable folders under `Personalizado` (feature - /// auto-custom-eq): id `eq_banda:`, title - /// ` · ` (e.g. `'60 Hz · +3 dB'`) so the - /// driver sees the effective custom gains at a glance. [actual] is the - /// persisted/effective custom preset resolved by the caller - /// ([presetPersonalizadoEfectivo]). - List bandasEq(PresetEcualizador actual) => [ - for ( - var i = 0; - i < actual.bandas.length && i < etiquetasBandasEq.length; - i++ - ) - _carpeta( - idBandaEq(i), - '${etiquetasBandasEq[i]} · ${formatearGananciaEq(actual.bandas[i])}', - ), - ]; - - /// The 13 playable gain leaves for band [indice] (feature auto-custom-eq): - /// -12..+12 dB in steps of 2, id `eq_gain::`. The currently - /// selected gain — when it falls on the 2 dB grid — is marked with a - /// leading `● ` so the active value is visible while browsing. An - /// out-of-range [indice] returns `[]`, never throws. - List gananciasBandaEq(int indice, PresetEcualizador actual) { - if (indice < 0 || indice >= actual.bandas.length) return const []; - final gananciaActual = actual.bandas[indice]; - return [ - for (var db = -12; db <= 12; db += 2) - MediaItem( - id: '$_prefijoGananciaEq$indice:$db', - title: - '${db.toDouble() == gananciaActual ? '● ' : ''}' - '${formatearGananciaEq(db.toDouble())}', - playable: true, - extras: _contentStyleGrid, - ), - ]; - } - /// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as /// direct leaves at the Favoritos root"): non-empty custom-group folders /// (phone order, capped at [_maxGruposPorFavoritos]), followed by @@ -906,7 +789,7 @@ Future reproducirPorMediaId( /// Fallback title (Design "Title = filename minus extension") for a blank /// or otherwise empty-after-stripping local filename — hardcoded Spanish, /// matching every other car-tree label in this file (`'Favoritos'`, -/// `'Ecualizador'`, etc.), none of which go through `AppLocalizations`. +/// `'Música Local'`, etc.), none of which go through `AppLocalizations`. const _tituloLocalFallback = 'Pista sin nombre'; /// Filename → display title (Design "Title = filename minus extension"): @@ -1292,259 +1175,6 @@ Future reproducirPistaLocal( await reproducir(item); } -/// Resolves an `eq_preset:` [id] to the matching [PresetEcualizador] -/// in [presets] by exact name (Design ADR-1, mirrors -/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix, -/// empty name, unmatched name) returns `null` instead of throwing (Spec -/// "Unknown or stale preset id"). -PresetEcualizador? resolverPresetEq(String id, List presets) { - if (!esPresetMediaId(id)) return null; - final nombre = id.substring(_prefijoPresetEq.length); - if (nombre.isEmpty) return null; - for (final preset in presets) { - if (preset.nombre == nombre) return preset; - } - return null; -} - -/// Formats a gain in dB for the Auto tree (feature auto-custom-eq): -/// explicit `+` for boosts, plain `0 dB` for neutral, ASCII `-` for cuts. -/// Non-integer factory-preset gains keep one decimal (`'+1.5 dB'`) so band -/// titles never lie about the effective value. -String formatearGananciaEq(double db) { - final esEntera = db == db.roundToDouble(); - final valor = esEntera ? db.round().toString() : db.toStringAsFixed(1); - return db > 0 ? '+$valor dB' : '$valor dB'; -} - -/// Parses an `eq_banda:` [id] into its band index (feature -/// auto-custom-eq). Any other shape (no prefix, non-integer, outside the -/// fixed [_numBandasEq]-band range) returns `null` instead of throwing. -int? indiceBandaEqDesde(String id) { - if (!esBandaEqMediaId(id)) return null; - final indice = int.tryParse(id.substring(_prefijoBandaEq.length)); - if (indice == null || indice < 0 || indice >= _numBandasEq) return null; - return indice; -} - -/// Parses an `eq_gain::` [id] into its `(indice, db)` pair -/// (feature auto-custom-eq): the prefix is stripped by length, the remainder -/// split on its single `:`. Any other shape (no prefix, missing/non-integer -/// fields, index outside the band range, gain outside -12..+12) returns -/// `null` instead of throwing. -(int indice, double db)? gananciaEqDesde(String id) { - if (!esGananciaEqMediaId(id)) return null; - final resto = id.substring(_prefijoGananciaEq.length); - final separador = resto.indexOf(':'); - if (separador <= 0) return null; - final indice = int.tryParse(resto.substring(0, separador)); - final db = int.tryParse(resto.substring(separador + 1)); - if (indice == null || db == null) return null; - if (indice < 0 || indice >= _numBandasEq) return null; - if (db < -12 || db > 12) return null; - return (indice, db.toDouble()); -} - -/// Persistence-targeting decision for a car EQ action — preset tap or band -/// change (feature auto-custom-eq): returns the deviceId to persist a -/// DEVICE-level entry for, or `null` to fall back to the global principal. -/// `null` cases mirror the phone hierarchy's own exclusions: multi-device -/// toggle off, unknown device (query failed/timed out — [dispositivo] is -/// `null`), built-in speaker (must keep falling through to L4 global), and -/// composite-placeholder BT ids (ADR-6). Pure — testable without platform -/// channels. -String? dispositivoDestinoEq({ - required bool multiDeviceEnabled, - required DispositivoAudio? dispositivo, -}) { - if (!multiDeviceEnabled || dispositivo == null) return null; - if (dispositivo.tipo == TipoDispositivo.altavozInterno) return null; - if (dispositivo.id.startsWith(prefijoPlaceholderBtName)) return null; - return dispositivo.id; -} - -/// Resolves the custom-EQ preset the Auto tree displays and edits (feature -/// auto-custom-eq): the device-level entry for [deviceId] when multi-device -/// is enabled and one exists, the global principal otherwise. Station/matrix -/// overrides are deliberately NOT consulted — the `Personalizado` tree -/// displays and edits exactly the level a gain tap persists to -/// ([dispositivoDestinoEq]), so what the driver sees is what a tap changes. -PresetEcualizador presetPersonalizadoEfectivo({ - required ConfiguracionEcualizador config, - required String? deviceId, -}) { - if (config.eqMultiDeviceEnabled && deviceId != null) { - final porDispositivo = config.presetsDispositivo[deviceId]; - if (porDispositivo != null) return porDispositivo; - } - return config.principal; -} - -/// Pure per-station apply gate (Design ADR-5), mirroring -/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic -/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied -/// live when there is no current station ([uuidActual] is `null`) or the -/// current station has no per-station preset override in -/// [clavesPorEmisora]. -bool debeAplicarPrincipalAhora({ - required String? uuidActual, - required Set clavesPorEmisora, -}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual); - -/// Apply-live gate for a DEVICE-targeted car selection (feature -/// auto-custom-eq), extending [debeAplicarPrincipalAhora] with the matrix -/// level of the phone hierarchy: the freshly persisted device-level preset -/// is audible now unless the current station carries a per-station override -/// or a `estación:dispositivoDestino` matrix entry shadows it. -bool debeAplicarSeleccionAhora({ - required String? uuidActual, - required Set clavesPorEmisora, - required Set clavesMatriz, - required String? deviceIdDestino, -}) { - if (uuidActual == null) return true; - if (clavesPorEmisora.contains(uuidActual)) return false; - if (deviceIdDestino != null && - clavesMatriz.contains('$uuidActual:$deviceIdDestino')) { - return false; - } - return true; -} - -/// Orchestrates an `eq_preset:` selection from the car (Design -/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq], -/// persists it, and conditionally applies it live via [aplicar]. -/// -/// Persistence targeting (feature auto-custom-eq): when the optional -/// [dispositivoDestino]/[persistirDispositivo] seams are provided and the -/// destination resolves to a deviceId ([dispositivoDestinoEq]'s decision), -/// the preset is persisted as a DEVICE-level entry — so the car selection -/// sticks for the car's output device instead of being shadowed by the -/// hierarchy's L3 lookup — and the live application is gated by -/// [debeAplicarSeleccionAhora]. Otherwise (seams omitted, or destination -/// `null`: toggle off, built-in, placeholder, query error/timeout) the -/// original global path runs unchanged: [persistirPrincipal] + -/// [debeAplicarPrincipalAhora]. -/// -/// This function's signature exposes ONLY the EQ persist/apply seams — it -/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so -/// there is no code path from a preset tap to playback (Design ADR-3, -/// non-playback invariant enforced structurally, not by discipline). An -/// unknown/stale [id] is a no-op: no seam is invoked and no exception -/// propagates (Spec "Unknown or stale preset id"). -Future aplicarPresetPorMediaId( - String id, { - required List presets, - required String? uuidActual, - required Future> Function() clavesPorEmisora, - required Future Function(PresetEcualizador) persistirPrincipal, - required Future Function(PresetEcualizador) aplicar, - Future Function()? dispositivoDestino, - Future Function(String deviceId, PresetEcualizador preset)? - persistirDispositivo, - Future> Function()? clavesMatriz, -}) async { - final preset = resolverPresetEq(id, presets); - if (preset == null) return; - - final destino = - (dispositivoDestino == null || persistirDispositivo == null) - ? null - : await dispositivoDestino(); - if (destino != null) { - // Apply-first ordering: if the live application throws, nothing has - // been persisted yet, so audible and persisted state cannot diverge. - if (debeAplicarSeleccionAhora( - uuidActual: uuidActual, - clavesPorEmisora: await clavesPorEmisora(), - clavesMatriz: - clavesMatriz == null ? const {} : await clavesMatriz(), - deviceIdDestino: destino, - )) { - await aplicar(preset); - } - await persistirDispositivo!(destino, preset); - return; - } - - await persistirPrincipal(preset); - if (debeAplicarPrincipalAhora( - uuidActual: uuidActual, - clavesPorEmisora: await clavesPorEmisora(), - )) { - await aplicar(preset); - } -} - -/// Orchestrates an `eq_gain::` selection from the car (feature -/// auto-custom-eq): parses [id] via [gananciaEqDesde], resolves the custom -/// base preset for the persistence target ([presetPersonalizadoEfectivo] -/// over [cargarConfig]'s snapshot), replaces the single band (the result is -/// always named `Personalizado` via `copyWithBandas`), persists it at DEVICE -/// level when [dispositivoDestino] yields a deviceId — global principal -/// otherwise, including the headless error/timeout fallback — and applies -/// the band live via [aplicarBanda] (the handler's `setBanda`, itself a -/// no-op while the EQ engine is unavailable). -/// -/// Live application is gated by [debeAplicarSeleccionAhora] over the same -/// optional [uuidActual]/[clavesPorEmisora]/[clavesMatriz] seams as -/// [aplicarPresetPorMediaId] (omitted seams keep the legacy always-apply -/// behavior), and runs BEFORE persistence so a throwing apply cannot leave -/// persisted and audible state divergent. -/// -/// Same non-playback structural invariant as [aplicarPresetPorMediaId]: no -/// playback seam exists in this signature. A malformed/out-of-range [id] or -/// a failing [cargarConfig] degrades to a no-op — no seam is invoked and no -/// exception propagates. -Future aplicarGananciaPorMediaId( - String id, { - required Future Function() cargarConfig, - required Future Function() dispositivoDestino, - required Future Function(String deviceId, PresetEcualizador preset) - persistirDispositivo, - required Future Function(PresetEcualizador preset) persistirPrincipal, - required Future Function(int indice, double db) aplicarBanda, - String? uuidActual, - Future> Function()? clavesPorEmisora, - Future> Function()? clavesMatriz, -}) async { - final ganancia = gananciaEqDesde(id); - if (ganancia == null) return; - final (indice, db) = ganancia; - - final ConfiguracionEcualizador config; - try { - config = await cargarConfig(); - } catch (_) { - return; - } - - final destino = await dispositivoDestino(); - final base = presetPersonalizadoEfectivo(config: config, deviceId: destino); - final bandas = List.from(base.bandas); - bandas[indice] = db; - final modificado = base.copyWithBandas(bandas); - - // Apply-first ordering (same rationale as [aplicarPresetPorMediaId]'s - // device branch): a throwing apply must not leave persisted state ahead - // of the audible one. - if (debeAplicarSeleccionAhora( - uuidActual: uuidActual, - clavesPorEmisora: - clavesPorEmisora == null ? const {} : await clavesPorEmisora(), - clavesMatriz: - clavesMatriz == null ? const {} : await clavesMatriz(), - deviceIdDestino: destino, - )) { - await aplicarBanda(indice, db); - } - if (destino != null) { - await persistirDispositivo(destino, modificado); - } else { - await persistirPrincipal(modificado); - } -} - /// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design /// "getChildren data source"). Reads favourites from SQLite and custom /// stations from the tolerant JSON file directly — both loadable without diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 8890d09..2999cca 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -4,13 +4,10 @@ import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart' show MethodChannel; import 'package:just_audio/just_audio.dart'; -import 'package:rxdart/rxdart.dart' show BehaviorSubject, ValueStream; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; -import '../modelos/dispositivo_audio.dart'; import '../modelos/emisora.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; @@ -19,8 +16,6 @@ import 'controlador_reconexion.dart'; import 'musica_local_auto.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; -import 'servicio_dispositivo_audio.dart'; -import 'servicio_ecualizador.dart'; /// Estado de reproducción expuesto al UI. enum EstadoReproduccion { @@ -494,7 +489,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler } /// Public entry point for EVERY external play (phone `reproducir`, car - /// `emisora:`/`grupo:`/`pista:`/`eq_preset:` non-path). ALWAYS clears the + /// `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 @@ -913,10 +908,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _androidAudioSessionIdSub?.cancel(); await _player.dispose(); await _androidAudioSessionIdController.close(); - for (final subject in _hijosSubjects.values) { - await subject.close(); - } - _hijosSubjects.clear(); } Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { @@ -937,88 +928,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// 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. - static const _canalDispositivos = MethodChannel('pluriwave/audio_devices'); - - /// Short timeout for the device query: on a headless Auto bind no - /// Activity (and thus no channel handler) exists, and a car tap must fall - /// back to global persistence instead of hanging. - static const _timeoutConsultaDispositivo = Duration(seconds: 2); - - /// Fresh active-output-device query for the car EQ paths. Returns `null` - /// on ANY failure (missing handler while headless, timeout, malformed - /// map) so callers degrade to global persistence — never a crash. - Future _dispositivoActivoAuto() async { - try { - final raw = await _canalDispositivos - .invokeMethod>('getActiveDevice') - .timeout(_timeoutConsultaDispositivo); - if (raw == null) return null; - return ServicioDispositivoAudioReal.dispositivoDesdeMapa( - Map.from(raw), - ); - } catch (_) { - return null; - } - } - - /// Resolves the persistence target for a car EQ action (feature - /// auto-custom-eq): a deviceId for a DEVICE-level write, `null` for the - /// global principal (toggle off, built-in speaker, placeholder id, or the - /// headless error/timeout fallback). - Future _dispositivoDestinoEqAuto( - ServicioEcualizador servicio, - ) async { - try { - final config = await servicio.cargar(); - if (!config.eqMultiDeviceEnabled) return null; - return dispositivoDestinoEq( - multiDeviceEnabled: config.eqMultiDeviceEnabled, - dispositivo: await _dispositivoActivoAuto(), - ); - } catch (_) { - return null; - } - } - - /// The custom preset the Auto tree shows and edits right now (feature - /// auto-custom-eq): the device-level entry for the current output device - /// when multi-device is on, the global principal otherwise — resolved - /// from persistence so a headless bind (no `EstadoEcualizador`) still - /// reports honest gains. - Future _presetPersonalizadoAuto() async { - final servicio = ServicioEcualizador(); - final config = await servicio.cargar(); - final destino = - config.eqMultiDeviceEnabled - ? dispositivoDestinoEq( - multiDeviceEnabled: true, - dispositivo: await _dispositivoActivoAuto(), - ) - : null; - return presetPersonalizadoEfectivo(config: config, deviceId: destino); - } - - /// Per-parent children-changed subjects (feature auto-custom-eq): - /// audio_service subscribes to [subscribeToChildren]'s stream the first - /// time the platform loads a parent's children and translates every later - /// emission into a native `notifyChildrenChanged`, making the car - /// re-request `getChildren` so band titles and the selection mark refresh - /// right after a gain tap. - final Map>> _hijosSubjects = {}; - - @override - ValueStream> subscribeToChildren(String parentMediaId) => - _hijosSubjects.putIfAbsent( - parentMediaId, - () => BehaviorSubject.seeded({}), - ); - - /// Emits a children-changed notification for [parentMediaId] — a no-op - /// until the platform has browsed that parent at least once. - void _notificarHijosCambiados(String parentMediaId) { - _hijosSubjects[parentMediaId]?.add({}); - } - @override Future> getChildren( String parentMediaId, [ @@ -1032,23 +941,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada(); return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal); } - if (parentMediaId == ConstructorArbolAuto.idEcualizador) { - return [ - ...constructor.presetsEq(PresetEcualizador.presets), - constructor.itemEqPersonalizado(), - ]; - } - if (parentMediaId == ConstructorArbolAuto.idEqPersonalizado) { - return constructor.bandasEq(await _presetPersonalizadoAuto()); - } - if (esBandaEqMediaId(parentMediaId)) { - final indice = indiceBandaEqDesde(parentMediaId); - if (indice == null) return const []; - return constructor.gananciasBandaEq( - indice, - await _presetPersonalizadoAuto(), - ); - } final musicaLocal = await hijosMusicaLocal( parentMediaId, fuente: fuenteLocal, @@ -1097,65 +989,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? extras, ]) async { try { - // EQ preset selection (Design ADR-3, Spec "EQ Preset Selection Applies - // Without Disturbing Playback"): FIRST branch, unconditional `return`, - // so an `eq_preset:` id can never fall through to the playback routing - // below. `aplicarPresetPorMediaId`'s seams are EQ-only (persist + - // apply) — there is no playback parameter to inject here. - if (esPresetMediaId(mediaId)) { - final servicio = ServicioEcualizador(); - // Persistence targeting (feature auto-custom-eq): with multi-device - // EQ on and a non-builtin output device active, the tap persists a - // DEVICE-level entry so the selection sticks for the car's device - // instead of being shadowed by the hierarchy; otherwise (or on any - // headless query failure) it persists the global principal as - // before. - await aplicarPresetPorMediaId( - mediaId, - presets: PresetEcualizador.presets, - uuidActual: emisoraActual?.uuid, - clavesPorEmisora: () async => - (await servicio.cargar()).porEmisora.keys.toSet(), - clavesMatriz: () async => - (await servicio.cargar()).presetsMatriz.keys.toSet(), - dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio), - persistirDispositivo: servicio.guardarPresetDispositivo, - persistirPrincipal: servicio.guardarPrincipal, - aplicar: aplicarPreset, - ); - return; - } - // Custom-EQ gain selection (feature auto-custom-eq): same - // unconditional-return shape as the eq_preset branch above — an - // `eq_gain:` id can never fall through to playback routing. - if (esGananciaEqMediaId(mediaId)) { - final servicio = ServicioEcualizador(); - await aplicarGananciaPorMediaId( - mediaId, - cargarConfig: servicio.cargar, - dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio), - persistirDispositivo: servicio.guardarPresetDispositivo, - persistirPrincipal: servicio.guardarPrincipal, - aplicarBanda: setBanda, - uuidActual: emisoraActual?.uuid, - clavesPorEmisora: () async => - (await servicio.cargar()).porEmisora.keys.toSet(), - clavesMatriz: () async => - (await servicio.cargar()).presetsMatriz.keys.toSet(), - ); - // Refresh the affected browse nodes so the band title under - // `Personalizado` and the `● ` selection mark reflect the new gain. - final ganancia = gananciaEqDesde(mediaId); - if (ganancia != null) { - _notificarHijosCambiados(ConstructorArbolAuto.idEqPersonalizado); - _notificarHijosCambiados(idBandaEq(ganancia.$1)); - } - return; - } // Local-track playback (Design "Local Track Playback Reuses Existing - // Pipeline", Spec "User selects a local track"): SECOND branch, - // unconditional `return`, mirroring the eq_preset branch above — a - // `pista:` id never falls through to the station routing below. + // 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; @@ -1166,10 +1003,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler ); return; } - // Folder-play actions (Design ADR-5, Phase 3 task 4.3): THIRD - // branch, after eq_preset/pista, before the station fallthrough — - // mirrors both branches above's unconditional-return shape so - // neither new action id can fall through to station routing. + // 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) || diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 7691dcc..c1b504a 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2,14 +2,11 @@ import 'dart:math' show Random; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/pista_local.dart'; -import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/musica_local_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; -import 'package:pluriwave/servicios/servicio_ecualizador.dart'; void main() { group('faviconUsable', () { @@ -226,41 +223,11 @@ void main() { group('ConstructorArbolAuto.raiz', () { test( - 'con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' - 'reproducibles con los ids esperados, Música Local en 4to lugar, ' - 'terminando en Ecualizador', + 'con incluirMusicaLocal: true devuelve exactamente 4 carpetas no ' + 'reproducibles con los ids esperados, terminando en Música Local', () { final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); - expect(raiz, hasLength(5)); - final ids = raiz.map((item) => item.id).toSet(); - expect( - ids, - equals({ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - ConstructorArbolAuto.idEcualizador, - }), - ); - for (final item in raiz) { - expect(item.playable, isFalse); - expect(item.title, isNotEmpty); - } - expect(raiz[3].id, ConstructorArbolAuto.idMusicaLocal); - expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); - expect(raiz.last.playable, isFalse); - }, - ); - - test( - 'con incluirMusicaLocal: false devuelve exactamente 4 carpetas, ' - 'byte-idéntico al comportamiento previo al cambio (regresión) — ' - 'Música Local queda OCULTA, no vacía', - () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); - expect(raiz, hasLength(4)); final ids = raiz.map((item) => item.id).toSet(); expect( @@ -269,40 +236,55 @@ void main() { ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, + ConstructorArbolAuto.idMusicaLocal, + }), + ); + for (final item in raiz) { + expect(item.playable, isFalse); + expect(item.title, isNotEmpty); + } + expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal); + }, + ); + + test( + 'con incluirMusicaLocal: false devuelve exactamente 3 carpetas — ' + 'Música Local queda OCULTA, no vacía', + () { + final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); + + expect(raiz, hasLength(3)); + final ids = raiz.map((item) => item.id).toSet(); + expect( + ids, + equals({ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, }), ); expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); - expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); - expect(raiz.last.playable, isFalse); }, ); - }); - group('esPresetMediaId', () { - test('reconoce ids con el prefijo eq_preset:, rechaza el resto', () { - final casos = { - 'eq_preset:Rock': true, - 'eq_preset:': true, - 'emisora:x': false, - 'grupo:g1': false, - '': false, - }; + test('la raíz no ofrece ninguna carpeta de ecualizador', () { + // EQ is phone-only: the car never browses or edits it. The right preset + // still reaches the car, applied automatically by EstadoEcualizador when + // the output device changes. + final ids = ConstructorArbolAuto() + .raiz(incluirMusicaLocal: true) + .map((item) => item.id) + .toList(); - casos.forEach((id, esperado) { - expect( - esPresetMediaId(id), - esperado, - reason: 'esPresetMediaId($id) debería ser $esperado', - ); - }); + expect(ids, isNot(contains('ecualizador'))); + expect(ids, isNot(contains('eq_custom'))); }); }); group('esCarpetaLocalMediaId / esPistaMediaId', () { test( 'reconocen sus prefijos, rechazan el resto y no colisionan entre sí ' - 'ni con emisora:/grupo:/eq_preset:/los ids de carpeta fijos', + 'ni con emisora:/grupo:/los ids de carpeta fijos', () { final builder = ConstructorArbolAuto(); @@ -314,11 +296,9 @@ void main() { final noLocales = [ 'emisora:x', 'grupo:g1', - 'eq_preset:Rock', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; @@ -381,8 +361,7 @@ void main() { final existentes = [ 'emisora:x', 'grupo:g1', - 'eq_preset:Rock', - 'carpeta_local:doc1', + 'carpeta_local:doc1', 'carpeta_local_pag:0:doc1', 'carpeta_local_ord:calidad:0:doc1', 'carpeta_local_bucket:0:0:doc1', @@ -416,7 +395,6 @@ void main() { expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse); expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse); expect(esPistaMediaId(reproducir), isFalse); - expect(esPresetMediaId(reproducir), isFalse); // Divergencia en el índice 14 (justo después de "carpeta_local_"): // 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) / @@ -534,7 +512,7 @@ void main() { () { test( 'reconoce el prefijo carpeta_local_pag: y no colisiona con ' - 'emisora:/grupo:/eq_preset:/carpeta_local:/pista: ni con los ids de ' + 'emisora:/grupo:/carpeta_local:/pista: ni con los ids de ' 'carpeta fijos', () { final builder = ConstructorArbolAuto(); @@ -551,13 +529,11 @@ void main() { final otros = [ 'emisora:x', 'grupo:g1', - 'eq_preset:Rock', - 'carpeta_local:doc1', + 'carpeta_local:doc1', 'pista:doc1', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; @@ -1456,14 +1432,12 @@ void main() { final otros = [ 'emisora:x', 'grupo:g1', - 'eq_preset:Rock', - 'carpeta_local:doc1', + 'carpeta_local:doc1', 'carpeta_local_pag:1:doc1', 'pista:doc1', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; @@ -2484,34 +2458,6 @@ void main() { ); }); - group('ConstructorArbolAuto.itemPresetEq', () { - test('mapea un PresetEcualizador a un item reproducible con id ' - 'eq_preset:', () { - final item = ConstructorArbolAuto().itemPresetEq( - PresetEcualizador.rock, - ); - - expect(item.id, 'eq_preset:${PresetEcualizador.rock.nombre}'); - expect(item.playable, isTrue); - expect(item.title, PresetEcualizador.rock.nombre); - }); - }); - - group('ConstructorArbolAuto.presetsEq', () { - test('devuelve exactamente 6 items reproducibles, uno por preset', () { - final items = ConstructorArbolAuto().presetsEq( - PresetEcualizador.presets, - ); - - expect(items, hasLength(6)); - for (var i = 0; i < items.length; i++) { - expect(items[i].id, 'eq_preset:${PresetEcualizador.presets[i].nombre}'); - expect(items[i].title, PresetEcualizador.presets[i].nombre); - expect(items[i].playable, isTrue); - } - }); - }); - group('ConstructorArbolAuto.itemEmisora', () { test('usa el favicon remoto como artUri cuando existe', () { final emisora = _emisora( @@ -2836,175 +2782,6 @@ void main() { expect(resultado, isNull); }); }); - - group('resolverPresetEq', () { - test('resuelve un nombre conocido al preset exacto', () { - final resultado = resolverPresetEq( - 'eq_preset:${PresetEcualizador.rock.nombre}', - PresetEcualizador.presets, - ); - - expect(resultado, equals(PresetEcualizador.rock)); - }); - - test( - 'devuelve null para nombre desconocido, id vacío-tras-prefijo o id sin ' - 'prefijo eq_preset:, sin lanzar', - () { - expect( - resolverPresetEq('eq_preset:Inexistente', PresetEcualizador.presets), - isNull, - ); - expect( - resolverPresetEq('eq_preset:', PresetEcualizador.presets), - isNull, - ); - expect( - resolverPresetEq('emisora:x', PresetEcualizador.presets), - isNull, - ); - }, - ); - }); - - group('debeAplicarPrincipalAhora', () { - test('true cuando no hay estación actual', () { - expect( - debeAplicarPrincipalAhora( - uuidActual: null, - clavesPorEmisora: const {'uuid-otra'}, - ), - isTrue, - ); - }); - - test('true cuando la estación actual no tiene override por-emisora', () { - expect( - debeAplicarPrincipalAhora( - uuidActual: 'uuid-sin-override', - clavesPorEmisora: const {'uuid-otra'}, - ), - isTrue, - ); - }); - - test('false cuando la estación actual tiene override por-emisora', () { - expect( - debeAplicarPrincipalAhora( - uuidActual: 'uuid-con-override', - clavesPorEmisora: const {'uuid-con-override'}, - ), - isFalse, - ); - }); - }); - - group('aplicarPresetPorMediaId', () { - test( - 'preset conocido: persiste siempre y aplica cuando el gate es true', - () async { - PresetEcualizador? persistido; - PresetEcualizador? aplicado; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.rock.nombre}', - presets: PresetEcualizador.presets, - uuidActual: null, - clavesPorEmisora: () async => {}, - persistirPrincipal: (preset) async { - persistido = preset; - }, - aplicar: (preset) async { - aplicado = preset; - }, - ); - - expect(persistido, equals(PresetEcualizador.rock)); - expect(aplicado, equals(PresetEcualizador.rock)); - }, - ); - - test( - 'preset conocido con override por-emisora en la estación actual: ' - 'persiste pero NO aplica en vivo (gate false)', - () async { - var persistirLlamadas = 0; - var aplicarLlamadas = 0; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.jazz.nombre}', - presets: PresetEcualizador.presets, - uuidActual: 'uuid-con-override', - clavesPorEmisora: () async => {'uuid-con-override'}, - persistirPrincipal: (preset) async { - persistirLlamadas++; - }, - aplicar: (preset) async { - aplicarLlamadas++; - }, - ); - - expect(persistirLlamadas, 1); - expect(aplicarLlamadas, 0); - }, - ); - - test( - 'id desconocido/obsoleto: no persiste ni aplica, no lanza excepción', - () async { - var persistirLlamadas = 0; - var aplicarLlamadas = 0; - - await aplicarPresetPorMediaId( - 'eq_preset:Inexistente', - presets: PresetEcualizador.presets, - uuidActual: null, - clavesPorEmisora: () async => {}, - persistirPrincipal: (preset) async { - persistirLlamadas++; - }, - aplicar: (preset) async { - aplicarLlamadas++; - }, - ); - - expect(persistirLlamadas, 0); - expect(aplicarLlamadas, 0); - }, - ); - - test( - 'invariante estructural: la firma solo expone seams de ' - 'persistencia/aplicación EQ, ningun seam de reproducción es ' - 'inyectable (ADR-3, no-playback-by-construction)', - () async { - // Este test documenta y prueba por construcción que - // aplicarPresetPorMediaId no puede tocar playback: los únicos - // parámetros de función inyectables en su firma son - // persistirPrincipal y aplicar (ambos EQ-only). No existe ningún - // parámetro playMediaItem/mediaItem/playbackState que un caller - // pueda pasar — de haberlo, este call site fallaría a compilar. - final llamadasAplicar = []; - final llamadasPersistir = []; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.pop.nombre}', - presets: PresetEcualizador.presets, - uuidActual: null, - clavesPorEmisora: () async => {}, - persistirPrincipal: (preset) async => llamadasPersistir.add(preset), - aplicar: (preset) async => llamadasAplicar.add(preset), - // NOTE: no `playMediaItem`/`mediaItem`/`playbackState` parameter - // exists on this function — there is nothing to pass here even - // if a caller wanted to. That absence IS the non-playback proof. - ); - - expect(llamadasPersistir, [PresetEcualizador.pop]); - expect(llamadasAplicar, [PresetEcualizador.pop]); - }, - ); - }); - group('reproducirPorMediaId', () { test( 'resuelve el id y delega a reproducir con un MediaItem con forma de ' @@ -3052,624 +2829,6 @@ void main() { }, ); }); - - // ─────────────────────────────────────────────────────────────────────────── - // EQ gráfico personalizado en Android Auto (feature auto-custom-eq) - // ─────────────────────────────────────────────────────────────────────────── - - group('ConstructorArbolAuto.itemEqPersonalizado', () { - test('carpeta no reproducible con id eq_custom y título Personalizado', () { - final item = ConstructorArbolAuto().itemEqPersonalizado(); - - expect(item.id, ConstructorArbolAuto.idEqPersonalizado); - expect(item.id, 'eq_custom'); - expect(item.title, 'Personalizado'); - expect(item.playable, isFalse); - }); - }); - - group('formatearGananciaEq', () { - test('positivos con signo +, cero sin signo, negativos con -', () { - expect(formatearGananciaEq(4), '+4 dB'); - expect(formatearGananciaEq(0), '0 dB'); - expect(formatearGananciaEq(-6), '-6 dB'); - expect(formatearGananciaEq(12), '+12 dB'); - expect(formatearGananciaEq(-12), '-12 dB'); - }); - - test('ganancias no enteras (presets de fábrica) muestran un decimal', () { - expect(formatearGananciaEq(1.5), '+1.5 dB'); - expect(formatearGananciaEq(-1.5), '-1.5 dB'); - }); - }); - - group('ConstructorArbolAuto.bandasEq', () { - test( - '5 carpetas eq_banda: con frecuencia y ganancia actual en el título', - () { - final preset = PresetEcualizador( - nombre: 'Personalizado', - bandas: [3.0, 0.0, -6.0, 1.5, 12.0], - ); - - final items = ConstructorArbolAuto().bandasEq(preset); - - expect(items, hasLength(5)); - expect(items.map((i) => i.id), [ - 'eq_banda:0', - 'eq_banda:1', - 'eq_banda:2', - 'eq_banda:3', - 'eq_banda:4', - ]); - expect(items[0].title, '60 Hz · +3 dB'); - expect(items[1].title, '250 Hz · 0 dB'); - expect(items[2].title, '1 kHz · -6 dB'); - expect(items[3].title, '4 kHz · +1.5 dB'); - expect(items[4].title, '16 kHz · +12 dB'); - expect(items.every((i) => i.playable == false), isTrue); - }, - ); - }); - - group('ConstructorArbolAuto.gananciasBandaEq', () { - test( - '13 items reproducibles eq_gain:: de -12 a +12 en pasos de 2', - () { - final items = ConstructorArbolAuto().gananciasBandaEq( - 1, - PresetEcualizador.flat, - ); - - expect(items, hasLength(13)); - expect(items.first.id, 'eq_gain:1:-12'); - expect(items.last.id, 'eq_gain:1:12'); - expect(items.every((i) => i.playable == true), isTrue); - expect(items.first.title, '-12 dB'); - expect(items.last.title, '+12 dB'); - }, - ); - - test('marca la ganancia actualmente seleccionada con "● "', () { - final preset = PresetEcualizador( - nombre: 'Personalizado', - bandas: [0.0, 4.0, 0.0, 0.0, 0.0], - ); - - final items = ConstructorArbolAuto().gananciasBandaEq(1, preset); - - final marcados = items.where((i) => i.title.startsWith('● ')).toList(); - expect(marcados, hasLength(1)); - expect(marcados.single.id, 'eq_gain:1:4'); - expect(marcados.single.title, '● +4 dB'); - }); - - test( - 'ganancia actual fuera de la retícula (impar) no marca ningún item', - () { - // Rock: bandas [2.0, 1.0, -1.0, 2.0, 3.0] — banda 1 vale 1.0. - final items = ConstructorArbolAuto().gananciasBandaEq( - 1, - PresetEcualizador.rock, - ); - - expect(items.where((i) => i.title.startsWith('● ')), isEmpty); - }, - ); - - test('índice fuera de rango devuelve lista vacía, nunca lanza', () { - final constructor = ConstructorArbolAuto(); - expect(constructor.gananciasBandaEq(-1, PresetEcualizador.flat), isEmpty); - expect(constructor.gananciasBandaEq(5, PresetEcualizador.flat), isEmpty); - }); - }); - - group('esBandaEqMediaId / indiceBandaEqDesde', () { - test('reconoce ids eq_banda: y extrae el índice', () { - expect(esBandaEqMediaId('eq_banda:0'), isTrue); - expect(esBandaEqMediaId('eq_banda:4'), isTrue); - expect(esBandaEqMediaId('eq_custom'), isFalse); - expect(esBandaEqMediaId('eq_gain:0:2'), isFalse); - - expect(indiceBandaEqDesde('eq_banda:0'), 0); - expect(indiceBandaEqDesde('eq_banda:4'), 4); - }); - - test('ids malformados o fuera de rango devuelven null, nunca lanzan', () { - expect(indiceBandaEqDesde('eq_banda:'), isNull); - expect(indiceBandaEqDesde('eq_banda:x'), isNull); - expect(indiceBandaEqDesde('eq_banda:5'), isNull); - expect(indiceBandaEqDesde('eq_banda:-1'), isNull); - expect(indiceBandaEqDesde('emisora:x'), isNull); - }); - }); - - group('esGananciaEqMediaId / gananciaEqDesde', () { - test('reconoce ids eq_gain: y extrae (índice, dB)', () { - expect(esGananciaEqMediaId('eq_gain:0:2'), isTrue); - expect(esGananciaEqMediaId('eq_banda:0'), isFalse); - - expect(gananciaEqDesde('eq_gain:0:2'), (0, 2.0)); - expect(gananciaEqDesde('eq_gain:4:-12'), (4, -12.0)); - expect(gananciaEqDesde('eq_gain:2:0'), (2, 0.0)); - }); - - test('ids malformados o fuera de rango devuelven null, nunca lanzan', () { - expect(gananciaEqDesde('eq_gain:'), isNull); - expect(gananciaEqDesde('eq_gain:0'), isNull); - expect(gananciaEqDesde('eq_gain:0:'), isNull); - expect(gananciaEqDesde('eq_gain:x:2'), isNull); - expect(gananciaEqDesde('eq_gain:0:x'), isNull); - expect(gananciaEqDesde('eq_gain:5:2'), isNull); - expect(gananciaEqDesde('eq_gain:0:14'), isNull); - expect(gananciaEqDesde('eq_gain:0:-14'), isNull); - expect(gananciaEqDesde('emisora:x'), isNull); - }); - }); - - group('dispositivoDestinoEq', () { - const btDevice = DispositivoAudio( - id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', - tipo: TipoDispositivo.bluetoothA2dp, - nombre: 'BT Speaker', - ); - const builtin = DispositivoAudio( - id: 'builtin_speaker', - tipo: TipoDispositivo.altavozInterno, - nombre: 'Speaker', - ); - const placeholder = DispositivoAudio( - id: 'bt_a2dp:name:My Speaker', - tipo: TipoDispositivo.bluetoothA2dp, - nombre: 'My Speaker', - ); - - test('dispositivo BT real con multi-device activo → su id', () { - expect( - dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: btDevice), - btDevice.id, - ); - }); - - test('multi-device apagado → null (persistencia global)', () { - expect( - dispositivoDestinoEq(multiDeviceEnabled: false, dispositivo: btDevice), - isNull, - ); - }); - - test('altavoz interno → null (nunca entrada por dispositivo)', () { - expect( - dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: builtin), - isNull, - ); - }); - - test('placeholder compuesto bt_a2dp:name: → null (ADR-6)', () { - expect( - dispositivoDestinoEq( - multiDeviceEnabled: true, - dispositivo: placeholder, - ), - isNull, - ); - }); - - test('dispositivo desconocido (consulta fallida) → null', () { - expect( - dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: null), - isNull, - ); - }); - }); - - group('presetPersonalizadoEfectivo', () { - const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; - - test('entrada por dispositivo gana cuando multi-device está activo', () { - final config = ConfiguracionEcualizador( - principal: PresetEcualizador.pop, - porEmisora: const {}, - eqMultiDeviceEnabled: true, - presetsDispositivo: {deviceId: PresetEcualizador.jazz}, - ); - - expect( - presetPersonalizadoEfectivo(config: config, deviceId: deviceId), - PresetEcualizador.jazz, - ); - }); - - test('sin entrada por dispositivo cae al principal', () { - final config = ConfiguracionEcualizador( - principal: PresetEcualizador.pop, - porEmisora: const {}, - eqMultiDeviceEnabled: true, - ); - - expect( - presetPersonalizadoEfectivo(config: config, deviceId: deviceId), - PresetEcualizador.pop, - ); - }); - - test('multi-device apagado ignora la entrada por dispositivo', () { - final config = ConfiguracionEcualizador( - principal: PresetEcualizador.pop, - porEmisora: const {}, - eqMultiDeviceEnabled: false, - presetsDispositivo: {deviceId: PresetEcualizador.jazz}, - ); - - expect( - presetPersonalizadoEfectivo(config: config, deviceId: deviceId), - PresetEcualizador.pop, - ); - }); - - test('deviceId null (destino global) cae al principal', () { - final config = ConfiguracionEcualizador( - principal: PresetEcualizador.pop, - porEmisora: const {}, - eqMultiDeviceEnabled: true, - presetsDispositivo: {deviceId: PresetEcualizador.jazz}, - ); - - expect( - presetPersonalizadoEfectivo(config: config, deviceId: null), - PresetEcualizador.pop, - ); - }); - }); - - group('debeAplicarSeleccionAhora', () { - const deviceId = 'bt_a2dp:AA:BB'; - - test('true sin estación actual', () { - expect( - debeAplicarSeleccionAhora( - uuidActual: null, - clavesPorEmisora: const {'uuid-otra'}, - clavesMatriz: const {}, - deviceIdDestino: deviceId, - ), - isTrue, - ); - }); - - test('false cuando la estación actual tiene override por-emisora', () { - expect( - debeAplicarSeleccionAhora( - uuidActual: 'uuid-con-override', - clavesPorEmisora: const {'uuid-con-override'}, - clavesMatriz: const {}, - deviceIdDestino: deviceId, - ), - isFalse, - ); - }); - - test( - 'false cuando existe entrada de matriz estación:dispositivo destino', - () { - expect( - debeAplicarSeleccionAhora( - uuidActual: 'uuid-1', - clavesPorEmisora: const {}, - clavesMatriz: const {'uuid-1:$deviceId'}, - deviceIdDestino: deviceId, - ), - isFalse, - ); - }, - ); - - test('true cuando la matriz solo cubre otro dispositivo', () { - expect( - debeAplicarSeleccionAhora( - uuidActual: 'uuid-1', - clavesPorEmisora: const {}, - clavesMatriz: const {'uuid-1:otro-dispositivo'}, - deviceIdDestino: deviceId, - ), - isTrue, - ); - }); - }); - - group('aplicarPresetPorMediaId — persistencia dirigida por dispositivo', () { - const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; - - test( - 'con destino de dispositivo persiste a nivel dispositivo, nunca al ' - 'principal, y aplica en vivo cuando no hay overrides', - () async { - final persistidosDispositivo = <(String, PresetEcualizador)>[]; - var persistirPrincipalLlamadas = 0; - PresetEcualizador? aplicado; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.rock.nombre}', - presets: PresetEcualizador.presets, - uuidActual: null, - clavesPorEmisora: () async => {}, - clavesMatriz: () async => {}, - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - persistidosDispositivo.add((id, preset)); - }, - persistirPrincipal: (preset) async { - persistirPrincipalLlamadas++; - }, - aplicar: (preset) async { - aplicado = preset; - }, - ); - - expect(persistidosDispositivo, [(deviceId, PresetEcualizador.rock)]); - expect(persistirPrincipalLlamadas, 0); - expect(aplicado, PresetEcualizador.rock); - }, - ); - - test( - 'con destino null (builtin/apagado/error) cae a la persistencia ' - 'principal como hasta ahora', - () async { - PresetEcualizador? persistidoPrincipal; - var persistirDispositivoLlamadas = 0; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.jazz.nombre}', - presets: PresetEcualizador.presets, - uuidActual: null, - clavesPorEmisora: () async => {}, - clavesMatriz: () async => {}, - dispositivoDestino: () async => null, - persistirDispositivo: (id, preset) async { - persistirDispositivoLlamadas++; - }, - persistirPrincipal: (preset) async { - persistidoPrincipal = preset; - }, - aplicar: (preset) async {}, - ); - - expect(persistidoPrincipal, PresetEcualizador.jazz); - expect(persistirDispositivoLlamadas, 0); - }, - ); - - test( - 'persiste a dispositivo pero NO aplica en vivo cuando la matriz ' - 'estación:dispositivo hace sombra', - () async { - var persistirDispositivoLlamadas = 0; - var aplicarLlamadas = 0; - - await aplicarPresetPorMediaId( - 'eq_preset:${PresetEcualizador.rock.nombre}', - presets: PresetEcualizador.presets, - uuidActual: 'uuid-1', - clavesPorEmisora: () async => {}, - clavesMatriz: () async => {'uuid-1:$deviceId'}, - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - persistirDispositivoLlamadas++; - }, - persistirPrincipal: (preset) async {}, - aplicar: (preset) async { - aplicarLlamadas++; - }, - ); - - expect(persistirDispositivoLlamadas, 1); - expect(aplicarLlamadas, 0); - }, - ); - }); - - group('aplicarGananciaPorMediaId', () { - const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; - - ConfiguracionEcualizador configCon({ - PresetEcualizador? principal, - Map? presetsDispositivo, - bool multiDevice = true, - }) => ConfiguracionEcualizador( - principal: principal ?? PresetEcualizador.flat, - porEmisora: const {}, - eqMultiDeviceEnabled: multiDevice, - presetsDispositivo: presetsDispositivo ?? const {}, - ); - - test( - 'con destino de dispositivo: modifica solo la banda, persiste como ' - 'Personalizado a nivel dispositivo y aplica la banda en vivo', - () async { - final base = PresetEcualizador.jazz; // [3, -1, -1.5, 2, 4] - final persistidosDispositivo = <(String, PresetEcualizador)>[]; - var persistirPrincipalLlamadas = 0; - final bandasAplicadas = <(int, double)>[]; - - await aplicarGananciaPorMediaId( - 'eq_gain:1:4', - cargarConfig: () async => - configCon(presetsDispositivo: {deviceId: base}), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - persistidosDispositivo.add((id, preset)); - }, - persistirPrincipal: (preset) async { - persistirPrincipalLlamadas++; - }, - aplicarBanda: (indice, db) async { - bandasAplicadas.add((indice, db)); - }, - ); - - expect(persistirPrincipalLlamadas, 0); - expect(persistidosDispositivo, hasLength(1)); - final (idPersistido, presetPersistido) = persistidosDispositivo.single; - expect(idPersistido, deviceId); - expect(presetPersistido.nombre, 'Personalizado'); - expect(presetPersistido.bandas, [3.0, 4.0, -1.5, 2.0, 4.0]); - expect(bandasAplicadas, [(1, 4.0)]); - }, - ); - - test( - 'con destino null persiste al principal partiendo de sus bandas', - () async { - PresetEcualizador? persistidoPrincipal; - var persistirDispositivoLlamadas = 0; - - await aplicarGananciaPorMediaId( - 'eq_gain:0:-2', - cargarConfig: () async => - configCon(principal: PresetEcualizador.pop, multiDevice: false), - dispositivoDestino: () async => null, - persistirDispositivo: (id, preset) async { - persistirDispositivoLlamadas++; - }, - persistirPrincipal: (preset) async { - persistidoPrincipal = preset; - }, - aplicarBanda: (indice, db) async {}, - ); - - expect(persistirDispositivoLlamadas, 0); - expect(persistidoPrincipal, isNotNull); - expect(persistidoPrincipal!.nombre, 'Personalizado'); - // Pop: [1.0, 1.5, 0.5, 1.0, 1.5] con banda 0 → -2. - expect(persistidoPrincipal!.bandas, [-2.0, 1.5, 0.5, 1.0, 1.5]); - }, - ); - - test('id malformado: no persiste, no aplica, no lanza', () async { - var llamadas = 0; - - await aplicarGananciaPorMediaId( - 'eq_gain:9:99', - cargarConfig: () async => configCon(), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - llamadas++; - }, - persistirPrincipal: (preset) async { - llamadas++; - }, - aplicarBanda: (indice, db) async { - llamadas++; - }, - ); - - expect(llamadas, 0); - }); - - test( - 'cargarConfig que lanza degrada a no-op sin propagar la excepción', - () async { - var llamadas = 0; - - await aplicarGananciaPorMediaId( - 'eq_gain:0:2', - cargarConfig: () async => throw Exception('prefs rotas'), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - llamadas++; - }, - persistirPrincipal: (preset) async { - llamadas++; - }, - aplicarBanda: (indice, db) async { - llamadas++; - }, - ); - - expect(llamadas, 0); - }, - ); - - test( - 'con override por-emisora de la estación actual persiste pero NO ' - 'aplica la banda en vivo', - () async { - final persistidosDispositivo = <(String, PresetEcualizador)>[]; - final bandasAplicadas = <(int, double)>[]; - - await aplicarGananciaPorMediaId( - 'eq_gain:1:4', - cargarConfig: () async => configCon(), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - persistidosDispositivo.add((id, preset)); - }, - persistirPrincipal: (preset) async {}, - aplicarBanda: (indice, db) async { - bandasAplicadas.add((indice, db)); - }, - uuidActual: 'uuid-1', - clavesPorEmisora: () async => {'uuid-1'}, - clavesMatriz: () async => {}, - ); - - expect(persistidosDispositivo, hasLength(1)); - expect(bandasAplicadas, isEmpty); - }, - ); - - test( - 'con entrada de matriz estación:dispositivo destino persiste pero NO ' - 'aplica la banda en vivo', - () async { - final persistidosDispositivo = <(String, PresetEcualizador)>[]; - final bandasAplicadas = <(int, double)>[]; - - await aplicarGananciaPorMediaId( - 'eq_gain:1:4', - cargarConfig: () async => configCon(), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async { - persistidosDispositivo.add((id, preset)); - }, - persistirPrincipal: (preset) async {}, - aplicarBanda: (indice, db) async { - bandasAplicadas.add((indice, db)); - }, - uuidActual: 'uuid-1', - clavesPorEmisora: () async => {}, - clavesMatriz: () async => {'uuid-1:$deviceId'}, - ); - - expect(persistidosDispositivo, hasLength(1)); - expect(bandasAplicadas, isEmpty); - }, - ); - - test( - 'con estación actual sin overrides sí aplica la banda en vivo', - () async { - final bandasAplicadas = <(int, double)>[]; - - await aplicarGananciaPorMediaId( - 'eq_gain:1:4', - cargarConfig: () async => configCon(), - dispositivoDestino: () async => deviceId, - persistirDispositivo: (id, preset) async {}, - persistirPrincipal: (preset) async {}, - aplicarBanda: (indice, db) async { - bandasAplicadas.add((indice, db)); - }, - uuidActual: 'uuid-1', - clavesPorEmisora: () async => {}, - clavesMatriz: () async => {}, - ); - - expect(bandasAplicadas, [(1, 4.0)]); - }, - ); - }); } /// Fake `metadatosDe` that always resolves to an empty map — used by every