import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart' as shimmer; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../pantallas/pantalla_reproductor.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluriwave_theme.dart'; /// Barra inferior persistente con controles básicos de reproducción. /// Toca la barra para abrir PantallaReproductor completa. /// /// Item 22 / audit 3.6 (t4:184-188): a 60px full-bleed opaque bar with /// station artwork -- replacing the former floating 999-radius glass pill, /// which had no artwork at all. 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 — see /// `mini_reproductor_configurar_test.dart`'s measurement assertion. Backs /// `PluriLayout.escucharBottomChromeInset` (ADR-7(b)). Item 22 fixes this /// bar's content to a t4:184 `height:60px` container, so the measured /// height is deterministic regardless of station-name text metrics. static const double altura = 60; @override State createState() => _MiniReproductorState(); } class _MiniReproductorState extends State { 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().configurarLocalizaciones( AppLocalizations.of(context), ); } } @override Widget build(BuildContext context) { final estado = context.watch(); 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); // Item 22 / audit 3.6 (t4:184-188): a 60px, full-bleed, OPAQUE bar // (rgba(16,37,50,.97) == listSurface at .97 alpha) with a top hairline — // no BackdropFilter, no side margins, no pill radius. `app.dart` no // longer wraps this widget in its own horizontal padding either (see // its `bottomNavigationBar` composition). return DecoratedBox( key: const ValueKey('mini-reproductor-superficie'), decoration: BoxDecoration( color: t.listSurface.withValues(alpha: 0.97), border: Border( top: BorderSide(color: Colors.white.withValues(alpha: 0.07)), ), ), child: SafeArea( top: false, child: SizedBox( height: 60, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Expanded( child: Semantics( button: true, label: l10n.miniPlayerOpenLabel(stationName), child: Material( color: Colors.transparent, child: InkWell( onTap: () => PantallaReproductor.abrir(context, emisora), child: Row( children: [ _ArteMiniReproductor(emisora: emisora), 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( 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 Row( mainAxisSize: MainAxisSize.min, children: [ // t4:187: a live station gets a // small teal dot before its label // ("En directo"). Static, not the // prototype's `pw-pulse` animation // — an infinite AnimationController // here would hang every existing // `pumpAndSettle()` call in this // widget's own test suite that // exercises a playing station. if (activo) ...[ Container( width: 6, height: 6, margin: const EdgeInsets.only( right: 5, ), decoration: BoxDecoration( color: t.liveGreen, shape: BoxShape.circle, ), ), ], Flexible( child: Text( _labelEstado(l10n, s), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of( context, ).textTheme.bodySmall?.copyWith( color: conexionEnProblema ? t.offlineAccent : activo ? t.liveGreen : Theme.of(context) .colorScheme .onSurface .withValues( alpha: 0.7, ), fontWeight: (activo || conexionEnProblema) ? FontWeight.w600 : FontWeight.w400, ), ), ), ], ); }, ), ], ), ), ], ), ), ), ), ), StreamBuilder( 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, // t4:188: pause_circle at 30px. icon: Icon( s == EstadoReproduccion.reproduciendo ? Icons.pause_circle_filled_rounded : Icons.play_circle_fill_rounded, size: 30, 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, }; } } /// Item 22 / audit 3.6 (t4:186): square station artwork, 42x42, radius 11 — /// the element the former glass pill never rendered at all. Mirrors the /// CachedNetworkImage/shimmer/fallback pattern already established by /// `TarjetaEmisora` and `_ArteEscuchar`, sized for this bar specifically. class _ArteMiniReproductor extends StatelessWidget { const _ArteMiniReproductor({required this.emisora}); final Emisora emisora; static const _lado = 42.0; static const _radio = 11.0; @override Widget build(BuildContext context) { final theme = Theme.of(context); return ClipRRect( key: const ValueKey('mini-reproductor-arte'), borderRadius: BorderRadius.circular(_radio), child: SizedBox( width: _lado, height: _lado, child: (emisora.favicon != null && emisora.favicon!.isNotEmpty) ? CachedNetworkImage( imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmer(theme), errorWidget: (_, __, ___) => _iconoFallback(theme), ) : _iconoFallback(theme), ), ); } Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors( baseColor: theme.colorScheme.surfaceContainerHighest, highlightColor: theme.colorScheme.surface, child: Container(color: theme.colorScheme.surfaceContainerHighest), ); Widget _iconoFallback(ThemeData theme) => Container( color: theme.colorScheme.primaryContainer, child: Icon( Icons.radio_rounded, size: 20, color: theme.colorScheme.onPrimaryContainer, ), ); }