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_ecualizador.dart'; import '../estado/estado_navegacion.dart'; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_audio.dart'; import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import '../widgets/visualizador_audio.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'pantalla_reproductor.dart'; import 'reproducir_minimizado.dart'; /// Pantalla principal: emisoras populares y por género. class PantallaInicio extends StatefulWidget { const PantallaInicio({super.key}); @override State createState() => _PantallaInicioState(); } class _PantallaInicioState extends State { @override Widget build(BuildContext context) { // S4-R5: no root watch on EstadoRadio. Every field is consumed through // context.select over identity-memoized getters, so audio buffer events // (which notify EstadoRadio) no longer rebuild this screen. final theme = Theme.of(context); final l10n = AppLocalizations.of(context); return CustomScrollView( slivers: [ // WU5 built the hero; WU6 relocated the discovery sections that // used to follow it (_seccionCercanas, _seccionTendencias, // _chipGeneros, _errorBanner, the browse grid) into // PantallaBuscar's landing state and DELETED them here (this WU's // own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's // content is now just the hero and the favorites preview below; // pull-to-refresh was dropped along with the grid it refreshed — // the retry button that used to live in the (now relocated) error // banner already covers manual recovery on Buscar. const SliverToBoxAdapter(child: _EscucharHero()), SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)), // ADR-7(b): the mini player is hidden on this whole screen // (app.dart), so its content needs less bottom padding than every // other root — escucharBottomChromeInset, not the plain // bottomChromeInset every other root/scrollable uses. const SliverToBoxAdapter( child: SizedBox(height: PluriLayout.escucharBottomChromeInset), ), ], ); } /// WU5 task 5.8: a preview of `listaFavoritos` (capped — full browsing, /// filtering, and reordering live on Favoritos itself, WU4), with "Ver /// todas" switching the root tab via `EstadoNavegacionRaiz.irA` rather /// than pushing a route (`app-navigation-shell` — Root-to-Root Switching /// Without Push). static const _capTusEmisoras = 6; Widget _seccionTusEmisoras( BuildContext context, ThemeData theme, AppLocalizations l10n, ) { final favoritos = context.select>( (e) => e.listaFavoritos, ); final mostrados = favoritos.take(_capTusEmisoras).toList(); return Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, 8, PluriLayout.horizontal, 0, ), child: PluriGlassSurface( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( l10n.yourStationsTitle, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), ), ), TextButton( onPressed: () => context.read().irA( RaizPluriWave.favoritos, ), child: Text(l10n.seeAllAction), ), ], ), if (mostrados.isEmpty) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( l10n.favoritesEmptySubtitle, style: theme.textTheme.bodySmall, ), ) else SizedBox( height: 76, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: mostrados.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) { final emisora = mostrados[i]; return SizedBox( width: 260, child: TarjetaEmisora( emisora: emisora, esCompacta: true, onTap: () => reproducirMinimizado(context, emisora), ), ); }, ), ), ], ), ), ); } } /// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the /// single source of truth (`emisoraActual` already feeds `MiniReproductor` /// and `PantallaReproductor`) — this hero is a third VIEW, never a third /// STATE. /// /// Binding rules this class exists to enforce: /// 1. StatelessWidget — no cached `Emisora`, no local playback flag. /// 2. Reads use `context.select` per scalar (here, `emisoraActual` itself — /// `Emisora`'s own `==`/`hashCode` are uuid-based, so this only rebuilds /// the hero when the STATION actually changes, not on every audio buffer /// event `EstadoRadio` also notifies on). Fast-changing playback status /// (`EstadoReproduccion`) is read via `StreamBuilder` instead, the same /// pattern `_Controles`/`MiniReproductor` already use, so status ticks /// don't even reach this widget's own rebuild path. /// 3. Transport calls the SAME `EstadoRadio`/`EstadoEcualizador` methods the /// full player calls — no new playback methods. /// 4. `VisualizadorAudio` is reused UNCHANGED, just re-parameterised /// (`barras: 30`, `altura: 26`, `color: liveGreen`). class _EscucharHero extends StatelessWidget { const _EscucharHero(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final emisora = context.select( (e) => e.emisoraActual, ); if (emisora == null) { return Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, 8, PluriLayout.horizontal, 0, ), child: PluriGlassSurface( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.nothingPlayingTitle, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), ), const SizedBox(height: 4), Text( l10n.nothingPlayingSubtitle, style: theme.textTheme.bodySmall, ), ], ), ), ); } final estado = context.read(); final stationName = localizedStationName(l10n, emisora.nombre); return Padding( padding: const EdgeInsets.fromLTRB( PluriLayout.horizontal, 8, PluriLayout.horizontal, 0, ), child: PluriGlassSurface( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ArteEscuchar(emisora: emisora), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ StreamBuilder( stream: estado.estadoStream, builder: (context, snap) { final s = snap.data ?? EstadoReproduccion.detenido; final enVivo = s == EstadoReproduccion.reproduciendo; return PluriStatusPill( icon: enVivo ? Icons.podcasts_rounded : Icons.pause_circle_outline_rounded, label: enVivo ? l10n.liveNow : l10n.notPlaying, accent: enVivo ? context.pluriTokens.liveGreen : null, ); }, ), const SizedBox(height: 6), Text( stationName, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), VisualizadorAudio( estadoStream: estado.estadoStream, androidAudioSessionIdStream: estado.audio.androidAudioSessionIdStream, barras: 30, altura: 26, color: context.pluriTokens.liveGreen, ), ], ), ), ], ), const SizedBox(height: 14), _FilaTransporteEscuchar(emisora: emisora), const SizedBox(height: 10), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( icon: const Icon(Icons.tune_rounded, size: 18), label: Text(l10n.openFullPlayerTooltip), onPressed: () => PantallaReproductor.abrir(context, emisora), ), ), ], ), ), ); } } /// Square art (the design's requested shape for the Escuchar hero — the /// full player's own `_WaveHero`, `pantalla_reproductor.dart`, stays /// circular and unchanged). class _ArteEscuchar extends StatelessWidget { const _ArteEscuchar({required this.emisora}); final Emisora emisora; static const _lado = 84.0; @override Widget build(BuildContext context) { final theme = Theme.of(context); final radius = BorderRadius.circular(context.pluriTokens.radiusMd); return PluriGlassSurface( padding: EdgeInsets.zero, borderRadius: radius, child: SizedBox( width: _lado, height: _lado, child: ClipRRect( borderRadius: radius, child: (emisora.favicon != null && emisora.favicon!.isNotEmpty) ? CachedNetworkImage( imageUrl: emisora.favicon!, fit: BoxFit.cover, placeholder: (_, __) => _shimmerCuadrado(theme), errorWidget: (_, __, ___) => _iconoFallback(theme), ) : _iconoFallback(theme), ), ), ); } Widget _shimmerCuadrado(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: 36, color: theme.colorScheme.onPrimaryContainer, ), ); } /// The hero's transport row — favorite / EQ toggle / stop / play-pause /// (primary) / sleep, in that order, with sleep as the documented 5th /// action. Every action calls an EXISTING `EstadoRadio`/`EstadoEcualizador` /// method — no new playback surface (ADR-7 rule 3). class _FilaTransporteEscuchar extends StatelessWidget { const _FilaTransporteEscuchar({required this.emisora}); final Emisora emisora; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final t = context.pluriTokens; final estado = context.read(); final esFavorito = context.select( (e) => e.listaFavoritos.any((x) => x.uuid == emisora.uuid), ); final eqActivo = context.select((e) => e.activo); final timerActivo = context.select( (e) => e.timer.activo, ); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( tooltip: esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip, icon: Icon( esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded, color: esFavorito ? Theme.of(context).colorScheme.error : null, ), onPressed: () => estado.toggleFavorito(emisora), ), IconButton( tooltip: eqActivo ? l10n.equalizerDisable : l10n.equalizerEnable, icon: Icon( eqActivo ? Icons.equalizer_rounded : Icons.equalizer_outlined, color: eqActivo ? t.warmCoral : null, ), onPressed: () => context.read().cambiarActivo(!eqActivo), ), StreamBuilder( stream: estado.estadoStream, builder: (context, snap) { final s = snap.data ?? EstadoReproduccion.detenido; final cargando = s == EstadoReproduccion.cargando || s == EstadoReproduccion.reconectando; return IconButton( tooltip: l10n.stopAction, icon: const Icon(Icons.stop_circle_outlined), onPressed: cargando ? null : estado.detenerReproduccion, ); }, ), StreamBuilder( stream: estado.estadoStream, builder: (context, snap) { final s = snap.data ?? EstadoReproduccion.detenido; final reproduciendo = s == EstadoReproduccion.reproduciendo; final cargando = s == EstadoReproduccion.cargando || s == EstadoReproduccion.reconectando; return SizedBox( width: 56, height: 56, child: FilledButton( style: FilledButton.styleFrom( shape: const CircleBorder(), padding: EdgeInsets.zero, ), onPressed: cargando ? null : () { if (reproduciendo || s == EstadoReproduccion.pausado) { estado.togglePlay(); } else { estado.reproducir(emisora); } }, child: cargando ? const SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( reproduciendo ? Icons.pause_rounded : Icons.play_arrow_rounded, size: 28, ), ), ); }, ), IconButton( tooltip: l10n.sleepTimer, icon: Icon( Icons.bedtime_rounded, color: timerActivo ? t.warmCoral : null, ), onPressed: () => _mostrarTimerSheet(context, estado, l10n), ), ], ); } Future _mostrarTimerSheet( BuildContext context, EstadoRadio estado, AppLocalizations l10n, ) { return showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.sleepTimer, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: 16), if (estado.timer.activo) FilledButton.tonal( onPressed: () { estado.cancelarTimer(); Navigator.pop(ctx); }, child: Text(l10n.cancelTimer), ) else Wrap( spacing: 8, runSpacing: 8, children: [ for (final segundos in estado.timerSuenoPresetsSegundos) ActionChip( label: Text(_formatearMinutos(l10n, segundos)), onPressed: () { estado.iniciarTimerDuracion( Duration(seconds: segundos), ); Navigator.pop(ctx); }, ), ], ), ], ), ), ), ); } String _formatearMinutos(AppLocalizations l10n, int segundos) { final d = Duration(seconds: segundos); final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); if (d.inHours > 0) { return l10n.durationHoursMinutesSeconds(d.inHours, m, s); } return d.inSeconds.remainder(60) == 0 ? l10n.durationMinutesOnly(d.inMinutes) : l10n.durationMinutesSeconds(d.inMinutes, s); } }