feat(escuchar): replace discovery browser with embedded player and favorites grid

Restructures PantallaInicio's top of screen: a new _EscucharHero
(square art, live/offline pill, VisualizadorAudio at barras: 30 /
altura: 26 / color: liveGreen, a 5-action transport row - favorite,
EQ toggle, stop, play/pause, sleep - plus a tool-tray entry chip
opening the full player) replaces the old PluriScreenHeader hero, and
a new "Tus emisoras" section (favorites preview, capped, "Ver todas")
follows it. Per design ADR-7, EstadoRadio stays the single source of
truth: the hero is a StatelessWidget with no cached fields, reading
emisoraActual via context.select (uuid-based equality scopes rebuilds
to real station changes) and the fast-changing playback status via
StreamBuilder, the same pattern _Controles/MiniReproductor already
use. The still-present discovery sections (_seccionCercanas onward,
including the old grid) are deliberately left in place - WU6
relocates them to Buscar and deletes them from here; removing them
now would leave that content nowhere until WU6 lands.

MiniReproductor gains a `visible` parameter (default true) and a
measured `static const double altura`. app.dart passes
`visible: indice != RaizPluriWave.escuchar.index`, hiding it visually
only (SizedBox.shrink()) while Escuchar is active, since the hero
already shows the same station - the State stays mounted so its
didChangeDependencies side effect (configurarLocalizaciones, S3-R3)
keeps running regardless of tab. altura was measured empirically
(72.0, via tester.getSize) rather than guessed, backing a new derived
PluriLayout.escucharBottomChromeInset constant now wired into
PantallaInicio's own bottom padding.

"Ver todas" switches roots via EstadoNavegacionRaiz.irA(favoritos),
verified via a NavigatorObserver asserting the push count is
unchanged (switches tabs, does not push).

Fixed a pre-existing test-infrastructure gap while writing the
anti-cache test: no test in this codebase had ever exercised
ServicioAudio.androidAudioSessionIdStream against a bare
FakeServicioAudio (pantalla_reproductor.dart has always read it but
has no test file at all) - the real getter needs registrarHandler()
(main.dart, production only) and threw otherwise. Added an empty
stream override to FakeServicioAudio, matching VisualizadorAudio's
own documented no-native-session fallback.

Tests: 614 -> 618 (2 skipped, unchanged). flutter analyze unchanged
at 1 pre-existing info. git diff empty for visualizador_audio.dart
and estado_radio.dart - this WU touches neither.
This commit is contained in:
2026-07-29 00:12:05 +02:00
parent 504a13641f
commit 3a803ce2bf
24 changed files with 1038 additions and 57 deletions
+16 -2
View File
@@ -14,7 +14,21 @@ import 'visualizador_audio.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
class MiniReproductor extends StatefulWidget {
const MiniReproductor({super.key});
const MiniReproductor({super.key, this.visible = true});
/// Design ADR-7(b): on Escuchar, the embedded hero already shows the same
/// station, so `_PaginaPrincipal` passes `visible: false` there to avoid
/// showing it twice. Hidden VISUALLY only (`build` returns
/// `SizedBox.shrink()`) — the widget stays in the tree and mounted, so
/// `didChangeDependencies`'s `configurarLocalizaciones` call (S3-R3) keeps
/// running on every locale change regardless of which tab is active.
final bool visible;
/// Measured (not guessed) from this widget's actual laid-out height with a
/// representative station name, default text scale and theme — see
/// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs
/// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)).
static const double altura = 72;
@override
State<MiniReproductor> createState() => _MiniReproductorState();
@@ -43,7 +57,7 @@ class _MiniReproductorState extends State<MiniReproductor> {
final l10n = AppLocalizations.of(context);
final emisora = estado.emisoraActual;
if (emisora == null) return const SizedBox.shrink();
if (!widget.visible || emisora == null) return const SizedBox.shrink();
final t = context.pluriTokens;
final stationName = localizedStationName(l10n, emisora.nombre);