Tint the mini player's reconnecting/error sub-states with the offlineAccent token (added in WU1, unused until now): the status label, the reconnect spinner, and the error retry icon now read as visually distinct "connectivity trouble" states instead of blending into the ordinary loading/paused look. Plain buffering keeps the default colour, confirmed by a dedicated regression test. Verify-first gate (task 16.1): ControladorReconexion.intentos exists, but ServicioAudio never surfaces it past a debug log line, and its estadoStream only carries the EstadoReproduccion enum. Adding an attempt-count label would require a getter/stream on servicio_audio.dart, one of the files this change must keep at an empty diff against main. Ship the restyle without the counter, per the risk register's own fallback. WU16.
265 lines
11 KiB
Dart
265 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_radio.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../pantallas/pantalla_reproductor.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import 'pluri_glass_surface.dart';
|
|
import 'pluri_icon.dart';
|
|
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, 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();
|
|
}
|
|
|
|
class _MiniReproductorState extends State<MiniReproductor> {
|
|
Locale? _localeConfigurado;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// S3-R3: configure localizations once per locale change — never from
|
|
// build(), which re-runs on every playback notification.
|
|
final locale = Localizations.localeOf(context);
|
|
if (_localeConfigurado != locale) {
|
|
_localeConfigurado = locale;
|
|
context.read<EstadoRadio>().configurarLocalizaciones(
|
|
AppLocalizations.of(context),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final estado = context.watch<EstadoRadio>();
|
|
final l10n = AppLocalizations.of(context);
|
|
final emisora = estado.emisoraActual;
|
|
|
|
if (!widget.visible || emisora == null) return const SizedBox.shrink();
|
|
|
|
final t = context.pluriTokens;
|
|
final stationName = localizedStationName(l10n, emisora.nombre);
|
|
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
t.spacingMd,
|
|
t.spacingSm,
|
|
t.spacingMd,
|
|
t.spacingSm,
|
|
),
|
|
child: PluriGlassSurface(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: t.spacingSm,
|
|
vertical: t.spacingXs,
|
|
),
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Semantics(
|
|
button: true,
|
|
label: l10n.miniPlayerOpenLabel(stationName),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: () => PantallaReproductor.abrir(context, emisora),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: t.spacingXs,
|
|
vertical: t.spacingXs,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Center(
|
|
child: IndicadorReproduccion(
|
|
estadoStream: estado.estadoStream,
|
|
color: t.electricMagenta,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: t.spacingSm),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
stationName,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snapshot) {
|
|
final s =
|
|
snapshot.data ??
|
|
EstadoReproduccion.detenido;
|
|
final activo =
|
|
s == EstadoReproduccion.reproduciendo;
|
|
// WU16: reconectando/error are
|
|
// "connectivity trouble" states —
|
|
// tinted with offlineAccent so they
|
|
// read as visually distinct from
|
|
// ordinary loading/paused/stopped.
|
|
final conexionEnProblema =
|
|
s ==
|
|
EstadoReproduccion.reconectando ||
|
|
s == EstadoReproduccion.error;
|
|
return Text(
|
|
_labelEstado(l10n, s),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
conexionEnProblema
|
|
? t.offlineAccent
|
|
: activo
|
|
? t.warmCoral
|
|
: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.7),
|
|
fontWeight:
|
|
(activo || conexionEnProblema)
|
|
? FontWeight.w600
|
|
: FontWeight.w400,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PluriIcon(
|
|
glyph: PluriIconGlyph.player,
|
|
variant: PluriIconVariant.activeGlow,
|
|
size: 18,
|
|
semanticLabel: l10n.playerIconLabel,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
StreamBuilder<EstadoReproduccion>(
|
|
stream: estado.estadoStream,
|
|
builder: (context, snapshot) {
|
|
final s = snapshot.data ?? EstadoReproduccion.detenido;
|
|
// S7-R3: reconectando is a transient stall — render it like
|
|
// cargando (spinner), never as the error/retry affordance.
|
|
if (s == EstadoReproduccion.cargando ||
|
|
s == EstadoReproduccion.reconectando) {
|
|
// WU16: only the reconectando sub-state gets the offline
|
|
// accent — plain cargando (e.g. the very first play)
|
|
// keeps the default spinner colour, since it is not a
|
|
// connectivity problem.
|
|
final reconectando = s == EstadoReproduccion.reconectando;
|
|
return SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: reconectando ? t.offlineAccent : null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (s == EstadoReproduccion.error) {
|
|
final emisoraActual = estado.emisoraActual;
|
|
return IconButton(
|
|
tooltip: l10n.retryAction,
|
|
icon: Icon(Icons.refresh_rounded, color: t.offlineAccent),
|
|
onPressed:
|
|
emisoraActual != null
|
|
? () => estado.reproducir(emisoraActual)
|
|
: null,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 48,
|
|
height: 48,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label:
|
|
s == EstadoReproduccion.reproduciendo
|
|
? l10n.pauseAction
|
|
: l10n.playAction,
|
|
child: IconButton(
|
|
tooltip:
|
|
s == EstadoReproduccion.reproduciendo
|
|
? l10n.pauseAction
|
|
: l10n.playAction,
|
|
icon: Icon(
|
|
s == EstadoReproduccion.reproduciendo
|
|
? Icons.pause_circle_filled_rounded
|
|
: Icons.play_circle_fill_rounded,
|
|
color: t.electricMagenta,
|
|
),
|
|
onPressed: estado.togglePlay,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: 48,
|
|
height: 48,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _labelEstado(AppLocalizations l10n, EstadoReproduccion estado) {
|
|
return switch (estado) {
|
|
EstadoReproduccion.cargando => l10n.playbackStatusConnecting,
|
|
EstadoReproduccion.reproduciendo => l10n.playbackStatusLive,
|
|
EstadoReproduccion.pausado => l10n.playbackStatusPaused,
|
|
EstadoReproduccion.reconectando => l10n.playbackStatusReconnecting,
|
|
EstadoReproduccion.error => l10n.playbackStatusConnectionError,
|
|
EstadoReproduccion.detenido => l10n.playbackStatusStopped,
|
|
};
|
|
}
|
|
}
|