refactor(auto): drop the in-car equalizer, keep EQ on the phone
The car tree carried a whole equalizer feature: an `Ecualizador` root folder with the six factory presets, a browsable `Personalizado` folder, five band folders and thirteen gain leaves each, plus the media-id namespaces, routing predicates, persistence-targeting and children-changed plumbing that fed them. Equalization is a phone task; the driver should not be tuning 5 bands from a car screen. Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/ gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo, dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora, aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto, _dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects notification machinery that existed only to refresh band titles after a gain tap. Deliberately KEPT: automatic per-device EQ. Reaching the car still applies that device's preset, because that lives in EstadoEcualizador and the output-device detection, not in this tree — it works with Android Auto or without it. Configuring is what moves to the phone; applying stays automatic. Also kept: the `eq_preset_*_v1` SharedPreferences keys in ServicioEcualizador, which share a name with the deleted media-id prefix by coincidence only and hold the phone's own presets. The root folder set goes from five entries to four (three without local music); its test now asserts no equalizer folder is offered at all, so a reintroduction has to be deliberate.
This commit is contained in:
@@ -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:<indice>`. 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:<indice>` 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:<indice>:<db>`. 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<MediaItem> 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:<nombre>` (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<MediaItem> presetsEq(List<PresetEcualizador> 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:<indice>`, title
|
||||
/// `<frecuencia> · <ganancia actual>` (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<MediaItem> 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:<indice>:<db>`. 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<MediaItem> 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<void> 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<void> reproducirPistaLocal(
|
||||
await reproducir(item);
|
||||
}
|
||||
|
||||
/// Resolves an `eq_preset:<nombre>` [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<PresetEcualizador> 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:<indice>` [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:<indice>:<db>` [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<String> 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<String> clavesPorEmisora,
|
||||
required Set<String> 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:<nombre>` 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<void> aplicarPresetPorMediaId(
|
||||
String id, {
|
||||
required List<PresetEcualizador> presets,
|
||||
required String? uuidActual,
|
||||
required Future<Set<String>> Function() clavesPorEmisora,
|
||||
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
||||
required Future<void> Function(PresetEcualizador) aplicar,
|
||||
Future<String?> Function()? dispositivoDestino,
|
||||
Future<void> Function(String deviceId, PresetEcualizador preset)?
|
||||
persistirDispositivo,
|
||||
Future<Set<String>> 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 <String>{} : 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:<indice>:<db>` 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<void> aplicarGananciaPorMediaId(
|
||||
String id, {
|
||||
required Future<ConfiguracionEcualizador> Function() cargarConfig,
|
||||
required Future<String?> Function() dispositivoDestino,
|
||||
required Future<void> Function(String deviceId, PresetEcualizador preset)
|
||||
persistirDispositivo,
|
||||
required Future<void> Function(PresetEcualizador preset) persistirPrincipal,
|
||||
required Future<void> Function(int indice, double db) aplicarBanda,
|
||||
String? uuidActual,
|
||||
Future<Set<String>> Function()? clavesPorEmisora,
|
||||
Future<Set<String>> 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<double>.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 <String>{} : await clavesPorEmisora(),
|
||||
clavesMatriz:
|
||||
clavesMatriz == null ? const <String>{} : 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
|
||||
|
||||
@@ -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<DispositivoAudio?> _dispositivoActivoAuto() async {
|
||||
try {
|
||||
final raw = await _canalDispositivos
|
||||
.invokeMethod<Map<dynamic, dynamic>>('getActiveDevice')
|
||||
.timeout(_timeoutConsultaDispositivo);
|
||||
if (raw == null) return null;
|
||||
return ServicioDispositivoAudioReal.dispositivoDesdeMapa(
|
||||
Map<String, dynamic>.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<String?> _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<PresetEcualizador> _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<String, BehaviorSubject<Map<String, dynamic>>> _hijosSubjects = {};
|
||||
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
||||
_hijosSubjects.putIfAbsent(
|
||||
parentMediaId,
|
||||
() => BehaviorSubject.seeded(<String, dynamic>{}),
|
||||
);
|
||||
|
||||
/// 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(<String, dynamic>{});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> 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<String, dynamic>? 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) ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user