Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
104 lines
4.7 KiB
Dart
104 lines
4.7 KiB
Dart
import '../modelos/pista_local.dart';
|
|
|
|
/// Immutable snapshot of a local-music play queue (Design ADR-1): an ordered
|
|
/// list of a folder's direct audio children plus the currently-playing
|
|
/// index. Pure Dart, no side effects — every "move" method returns a NEW
|
|
/// instance rather than mutating in place, mirroring how
|
|
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
|
|
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
|
|
/// the handler. (That last clause used to read "which cannot be instantiated
|
|
/// in unit tests"; it can — see `construirControlesTransporte`'s doc in
|
|
/// `servicio_audio.dart`. Keeping the queue logic out of the handler is
|
|
/// still worth it, but for design reasons, not for that one.)
|
|
class ColaLocal {
|
|
const ColaLocal({required this.pistas, this.indice = 0});
|
|
|
|
/// The folder's direct audio children, in play order — already
|
|
/// name-sorted or Fisher-Yates shuffled by the caller before
|
|
/// construction (Design ADR-6). This class itself never re-orders them.
|
|
final List<NodoLocal> pistas;
|
|
|
|
/// The currently-playing position within [pistas].
|
|
final int indice;
|
|
|
|
/// Whether [indice] points at a real entry in [pistas] (Design "empty
|
|
/// list" edge case: an empty queue has no current track).
|
|
bool get hayActual => indice >= 0 && indice < pistas.length;
|
|
|
|
/// The track at [indice]. Only meaningful when [hayActual] is `true` —
|
|
/// callers must check it first; this getter does not guard the read
|
|
/// itself.
|
|
NodoLocal get actual => pistas[indice];
|
|
|
|
/// The queue advanced one position (Design ADR-3's advance flow):
|
|
/// `null` when [indice] is already the last position (or [pistas] is
|
|
/// empty) — end-of-queue, per Design ADR-4 (no wraparound to track 1).
|
|
ColaLocal? conSiguiente() {
|
|
final siguiente = indice + 1;
|
|
if (siguiente >= pistas.length) return null;
|
|
return ColaLocal(pistas: pistas, indice: siguiente);
|
|
}
|
|
|
|
/// The queue moved back one position, CLAMPED at 0 (Design ADR-4:
|
|
/// `skipToPrevious` at the first track restarts it instead of throwing or
|
|
/// wrapping to the last track).
|
|
ColaLocal conAnterior() {
|
|
final anterior = indice > 0 ? indice - 1 : 0;
|
|
return ColaLocal(pistas: pistas, indice: anterior);
|
|
}
|
|
}
|
|
|
|
/// Outcome of [decidirAvanceCola] (Design ADR-3): a pure decision the caller
|
|
/// (`PluriWaveAudioHandler._manejarFinPista`) acts on with
|
|
/// static-review-only field mutations — this function itself has no side
|
|
/// effects.
|
|
enum DecisionAvanceCola {
|
|
/// No advance: not a completion event, no active local queue (radio
|
|
/// isolation), or the re-entry latch is already armed (an advance is
|
|
/// already resolving its URI).
|
|
ninguna,
|
|
|
|
/// Advance to the next track — the caller marks the re-entry latch and
|
|
/// resolves/plays `colaLocal.conSiguiente()`.
|
|
avanzar,
|
|
|
|
/// The queue reached its end — the caller deactivates the queue and stops
|
|
/// playback (Design ADR-4, no wraparound).
|
|
desactivar,
|
|
}
|
|
|
|
/// Pure queue-advance decision (Design ADR-3), extracted so it is
|
|
/// unit-testable without instantiating `PluriWaveAudioHandler` — the
|
|
/// established `ControladorReconexion` precedent (see this file's class
|
|
/// doc comment). Deliberately takes NO `just_audio` type: the caller maps
|
|
/// `ProcessingState.completed` to [trackCompletado] before calling this.
|
|
///
|
|
/// Double-gated: [trackCompletado] must be `true` AND [colaLocal] must be
|
|
/// non-null (radio never sets `_colaLocal`, so a `null` queue is a
|
|
/// structural proxy for "not local-queue playback" — Design "single
|
|
/// load-bearing invariant"). [avanzandoCola] is the re-entry latch: a
|
|
/// second `completed` emission while an earlier advance is still resolving
|
|
/// its URI must be a no-op, not a double-advance.
|
|
DecisionAvanceCola decidirAvanceCola({
|
|
required ColaLocal? colaLocal,
|
|
required bool avanzandoCola,
|
|
required bool trackCompletado,
|
|
}) {
|
|
if (!trackCompletado) return DecisionAvanceCola.ninguna;
|
|
if (colaLocal == null) return DecisionAvanceCola.ninguna;
|
|
if (avanzandoCola) return DecisionAvanceCola.ninguna;
|
|
return colaLocal.conSiguiente() == null
|
|
? DecisionAvanceCola.desactivar
|
|
: DecisionAvanceCola.avanzar;
|
|
}
|
|
|
|
/// Mid-await race guard (Design ADR-3's advance flow): `true` only when
|
|
/// [siguienteEsperado] is the EXACT SAME instance still installed as
|
|
/// [colaLocalActual] — a deliberate `identical()` check, NOT `==`, so a
|
|
/// user action that replaced `_colaLocal` with a structurally-equal-but
|
|
/// distinct `ColaLocal` during the async URI-resolve gap is correctly
|
|
/// detected as stale and aborts the advance, instead of silently racing an
|
|
/// external play/stop.
|
|
bool avanceEsValido(ColaLocal? colaLocalActual, ColaLocal? siguienteEsperado) =>
|
|
identical(colaLocalActual, siguienteEsperado);
|