Files
pluriwave/lib/servicios/cola_local.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
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.
2026-08-31 14:34:49 +02:00

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);