feat(reproductor): restructure full player with tool-tray and EQ sheet
Restructure pantalla_reproductor.dart onto PluriPushScaffold (design ADR-2 - this screen is the documented single consumer of titleOverride, a centered live/not-playing status pill, and the non-default keyboard_arrow_down leadingIcon). Square art replaces the old circular hero, favorite moves from the AppBar into the transport row (the redundant live-indicator dot is dropped - the AppBar pill already covers that signal), the old separate info chips collapse into a single subtitle line, and a new quality row surfaces codec/bitrate with a "Cambiar" action that reconnects the current stream (this app has no per-station alternate-quality capability to invoke, so this reuses the same reproducir() call the existing error-state retry button already uses, rather than a dead button or an invented picker). The always-expanded recording panel and the standalone sleep-timer button both become tool-tray tiles (EQ propio / Grabar / sleep timer / Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet hosting EcualizadorWidget - the exact same component WU13 restyled for Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora per-station persistence path. No second editor was created; the multi-device-eq resolution hierarchy is untouched. pantalla_reproductor.dart had zero test coverage before this commit (907 lines) - writing it first surfaced two pre-existing bugs blocking any coverage at all, both fixed: initState called estado.reproducir() directly, which notifies listeners synchronously before its first await and threw "setState() during build" the instant the screen mounted against a fresh Provider tree (fixed via addPostFrameCallback); and the body Column had no scrollable ancestor and overflowed even a generously tall viewport (fixed by wrapping it in a SingleChildScrollView, a real UX improvement and not just a test workaround). The three protected EQ test files (servicio_ecualizador_test.dart, estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713. size:exception - realized 1,410 changed lines (25 files including this docs update) against the 450-600 forecast: the restructured screen file alone is 658 lines (a near-total rewrite of a 907-line file, not a patch), its new test file (first-ever coverage) is 519 lines, and a new test fake plus a togglePlay() override account for the rest. Not splittable: the restructure, the tool tray, and the EQ-sheet wiring are one cohesive change to one screen.
This commit is contained in:
@@ -701,6 +701,8 @@
|
||||
"notPlaying": "Not playing",
|
||||
"oneTimeOption": "Once",
|
||||
"pausePlaybackTooltip": "Pause playback",
|
||||
"playerQualityChangeAction": "Change",
|
||||
"playerToolEqLabel": "Own EQ",
|
||||
"qualityOriginal": "Original quality: {quality}",
|
||||
"@qualityOriginal": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -657,6 +657,8 @@
|
||||
"notPlaying": "No está reproduciendo",
|
||||
"oneTimeOption": "Una vez",
|
||||
"pausePlaybackTooltip": "Pausar reproducción",
|
||||
"playerQualityChangeAction": "Cambiar",
|
||||
"playerToolEqLabel": "EQ propio",
|
||||
"qualityOriginal": "Calidad original: {quality}",
|
||||
"@qualityOriginal": {"placeholders": {"quality": {}}},
|
||||
"qualityUnknown": "Calidad no informada",
|
||||
|
||||
@@ -2480,6 +2480,18 @@ abstract class AppLocalizations {
|
||||
/// **'Pausar reproducción'**
|
||||
String get pausePlaybackTooltip;
|
||||
|
||||
/// No description provided for @playerQualityChangeAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Cambiar'**
|
||||
String get playerQualityChangeAction;
|
||||
|
||||
/// No description provided for @playerToolEqLabel.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'EQ propio'**
|
||||
String get playerToolEqLabel;
|
||||
|
||||
/// No description provided for @qualityOriginal.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
|
||||
@@ -1360,6 +1360,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'إيقاف مؤقت';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'الجودة الأصلية: $quality';
|
||||
|
||||
@@ -1369,6 +1369,12 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'প্লেব্যাক বিরতি';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'মূল মান: $quality';
|
||||
|
||||
@@ -1377,6 +1377,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Wiedergabe pausieren';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Originalqualität: $quality';
|
||||
|
||||
@@ -1361,6 +1361,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Pause playback';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Change';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'Own EQ';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Original quality: $quality';
|
||||
|
||||
@@ -1372,6 +1372,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Pausar reproducción';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Calidad original: $quality';
|
||||
|
||||
@@ -1382,6 +1382,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Mettre en pause';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Qualité d’origine : $quality';
|
||||
|
||||
@@ -1367,6 +1367,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'प्लेबैक रोकें';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'मूल गुणवत्ता: $quality';
|
||||
|
||||
@@ -1373,6 +1373,12 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Jeda pemutaran';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Kualitas asli: $quality';
|
||||
|
||||
@@ -1377,6 +1377,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Pausa riproduzione';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Qualità originale: $quality';
|
||||
|
||||
@@ -1329,6 +1329,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => '再生を一時停止';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return '元の音質: $quality';
|
||||
|
||||
@@ -1369,6 +1369,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Pausar reprodução';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Qualidade original: $quality';
|
||||
|
||||
@@ -1373,6 +1373,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => 'Пауза';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return 'Исходное качество: $quality';
|
||||
|
||||
@@ -1324,6 +1324,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get pausePlaybackTooltip => '暂停播放';
|
||||
|
||||
@override
|
||||
String get playerQualityChangeAction => 'Cambiar';
|
||||
|
||||
@override
|
||||
String get playerToolEqLabel => 'EQ propio';
|
||||
|
||||
@override
|
||||
String qualityOriginal(Object quality) {
|
||||
return '原始质量:$quality';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share;
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
@@ -12,14 +13,29 @@ import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../widgets/ecualizador_widget.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_wave_scaffold.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import '../widgets/pluri_push_scaffold.dart';
|
||||
import '../widgets/visualizador_audio.dart';
|
||||
|
||||
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
|
||||
/// is the ONE documented consumer of `titleOverride` (the centered live/not
|
||||
/// -playing pill) and of a non-default `leadingIcon`
|
||||
/// (`keyboard_arrow_down_rounded`, this screen dismisses down, not back).
|
||||
/// Square art (was circular), a single subtitle line, a quality row, and a
|
||||
/// 4-tile tool tray (EQ propio / Grabar / sleep timer / Compartir) replace
|
||||
/// the old info-chip row, always-expanded recording panel and standalone
|
||||
/// sleep-timer button. The per-station EQ sheet reuses [EcualizadorWidget]
|
||||
/// by its exact runtime type (design ADR-5) — no second editor.
|
||||
class PantallaReproductor extends StatefulWidget {
|
||||
final Emisora emisora;
|
||||
|
||||
const PantallaReproductor({super.key, required this.emisora});
|
||||
/// Injected for tests (mirrors `pantalla_grabaciones.dart`'s WU15
|
||||
/// `compartir` pattern) — defaults to the real `share_plus` call.
|
||||
final Future<void> Function(String texto)? compartir;
|
||||
|
||||
const PantallaReproductor({super.key, required this.emisora, this.compartir});
|
||||
|
||||
static Future<void> abrir(BuildContext context, Emisora emisora) {
|
||||
return Navigator.push(
|
||||
@@ -46,18 +62,21 @@ class PantallaReproductor extends StatefulWidget {
|
||||
State<PantallaReproductor> createState() => _PantallaReproductorState();
|
||||
}
|
||||
|
||||
class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _pulseController;
|
||||
class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
late final Future<void> Function(String) _compartir =
|
||||
widget.compartir ?? (texto) => Share.share(texto);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
_iniciarReproduccion();
|
||||
// Bugfix (surfaced by this screen's first-ever test coverage, WU14):
|
||||
// EstadoRadio.reproducir() calls notifyListeners() synchronously before
|
||||
// its first await when no recording is active, which previously threw
|
||||
// "setState() or markNeedsBuild() called during build" the instant this
|
||||
// screen mounted with a fresh Provider tree (e.g. every widget test that
|
||||
// pumps this screen for the first time). Deferring to the post-frame
|
||||
// callback keeps the exact same effect one frame later, outside build.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _iniciarReproduccion());
|
||||
}
|
||||
|
||||
Future<void> _iniciarReproduccion() async {
|
||||
@@ -67,12 +86,6 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -87,46 +100,29 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
(e) => e.uuid == emisoraActiva.uuid,
|
||||
);
|
||||
|
||||
return PluriWaveScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32),
|
||||
tooltip: l10n.closeAction,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
eq.activo ? Icons.equalizer_rounded : Icons.equalizer_outlined,
|
||||
color: eq.activo ? tokens.warmCoral : null,
|
||||
),
|
||||
tooltip: eq.activo ? l10n.equalizerDisable : l10n.equalizerEnable,
|
||||
onPressed: () => eq.cambiarActivo(!eq.activo),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
esFavorito
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_rounded,
|
||||
color: esFavorito ? theme.colorScheme.error : null,
|
||||
),
|
||||
tooltip:
|
||||
esFavorito
|
||||
? l10n.favoritesRemoveTooltip
|
||||
: l10n.favoritesAddTooltip,
|
||||
onPressed: () async => estado.toggleFavorito(emisoraActiva),
|
||||
),
|
||||
],
|
||||
return PluriPushScaffold(
|
||||
title: emisoraActiva.nombre,
|
||||
leadingIcon: Icons.keyboard_arrow_down_rounded,
|
||||
titleOverride: StreamBuilder<EstadoReproduccion>(
|
||||
stream: estado.estadoStream,
|
||||
builder: (context, snapshot) {
|
||||
final enVivo = snapshot.data == EstadoReproduccion.reproduciendo;
|
||||
return PluriStatusPill(
|
||||
icon:
|
||||
enVivo
|
||||
? Icons.podcasts_rounded
|
||||
: Icons.pause_circle_outline_rounded,
|
||||
label: enVivo ? l10n.liveNow : l10n.notPlaying,
|
||||
accent: enVivo ? tokens.liveGreen : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_WaveHero(
|
||||
_ArteReproductor(
|
||||
emisora: emisoraActiva,
|
||||
estadoStream: estado.estadoStream,
|
||||
).pluriScaleIn(
|
||||
@@ -145,23 +141,15 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
|
||||
const SizedBox(height: 10),
|
||||
_InfoChips(emisora: emisoraActiva).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 200),
|
||||
beginY: 0.2,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
|
||||
Text(
|
||||
_codecInfo(context, emisoraActiva),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 250),
|
||||
),
|
||||
_SubtituloInfo(
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
|
||||
const SizedBox(height: 12),
|
||||
_FilaCalidad(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
|
||||
const SizedBox(height: 14),
|
||||
PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
@@ -177,54 +165,48 @@ class _PantallaReproductorState extends State<PantallaReproductor>
|
||||
color: tokens.warmCoral,
|
||||
altura: 46,
|
||||
),
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 280)),
|
||||
const Spacer(),
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
|
||||
const SizedBox(height: 22),
|
||||
_Controles(
|
||||
estado: estado,
|
||||
emisora: emisoraActiva,
|
||||
esFavorito: esFavorito,
|
||||
).pluriFadeSlideIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 300),
|
||||
delay: const Duration(milliseconds: 310),
|
||||
beginY: 0.3,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _GrabacionWidget().pluriFadeIn(
|
||||
context,
|
||||
delay: const Duration(milliseconds: 360),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_TimerWidget(
|
||||
const SizedBox(height: 20),
|
||||
_BandejaHerramientas(
|
||||
estado: estado,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 400)),
|
||||
const SizedBox(height: 16),
|
||||
eq: eq,
|
||||
emisora: emisoraActiva,
|
||||
compartir: _compartir,
|
||||
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _codecInfo(BuildContext context, Emisora e) {
|
||||
final parts = <String>[];
|
||||
if (e.codec != null) parts.add(e.codec!.toUpperCase());
|
||||
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
|
||||
return parts.isEmpty
|
||||
? AppLocalizations.of(context).qualityUnknown
|
||||
: AppLocalizations.of(context).qualityOriginal(parts.join(' · '));
|
||||
}
|
||||
}
|
||||
|
||||
class _WaveHero extends StatelessWidget {
|
||||
/// Square art (design proposal WU14 row: "square art" replaces the old
|
||||
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
|
||||
/// unchanged from the prior circular version — only the clip shape and the
|
||||
/// decorative halo geometry changed from circle to rounded-square.
|
||||
class _ArteReproductor extends StatelessWidget {
|
||||
final Emisora emisora;
|
||||
final Stream<EstadoReproduccion> estadoStream;
|
||||
|
||||
const _WaveHero({required this.emisora, required this.estadoStream});
|
||||
const _ArteReproductor({required this.emisora, required this.estadoStream});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = context.pluriTokens;
|
||||
final size = MediaQuery.of(context).size.width * 0.62;
|
||||
final radio = BorderRadius.circular(t.radiusLg);
|
||||
|
||||
return StreamBuilder<EstadoReproduccion>(
|
||||
stream: estadoStream,
|
||||
@@ -237,6 +219,7 @@ class _WaveHero extends StatelessWidget {
|
||||
final hayError = snapshot.data == EstadoReproduccion.error;
|
||||
|
||||
return SizedBox(
|
||||
key: const Key('player-hero-art'),
|
||||
width: size + 40,
|
||||
height: size + 40,
|
||||
child: Stack(
|
||||
@@ -246,7 +229,7 @@ class _WaveHero extends StatelessWidget {
|
||||
width: size + 34,
|
||||
height: size + 34,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 16),
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
t.electricMagenta.withValues(
|
||||
@@ -261,17 +244,18 @@ class _WaveHero extends StatelessWidget {
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
borderRadius: BorderRadius.circular(t.radiusLg + 4),
|
||||
border: Border.all(color: t.glassBorder),
|
||||
),
|
||||
),
|
||||
PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(size),
|
||||
borderRadius: radio,
|
||||
padding: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ClipOval(
|
||||
child: ClipRRect(
|
||||
borderRadius: radio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -335,44 +319,93 @@ class _WaveHero extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class _InfoChips extends StatelessWidget {
|
||||
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
|
||||
/// separate chips — country/language now join as one line; codec/bitrate
|
||||
/// moved into their own [_FilaCalidad] row below).
|
||||
class _SubtituloInfo extends StatelessWidget {
|
||||
const _SubtituloInfo({required this.emisora});
|
||||
|
||||
final Emisora emisora;
|
||||
const _InfoChips({required this.emisora});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final items = <String>[];
|
||||
if (emisora.pais != null) items.add(emisora.pais!);
|
||||
if (emisora.idioma != null) items.add(emisora.idioma!);
|
||||
if ((emisora.bitrate ?? 0) > 0) items.add('${emisora.bitrate} kbps');
|
||||
if (emisora.codec != null) items.add(emisora.codec!.toUpperCase());
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
final partes = <String>[
|
||||
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
|
||||
if (emisora.idioma != null && emisora.idioma!.isNotEmpty) emisora.idioma!,
|
||||
];
|
||||
if (partes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children:
|
||||
items
|
||||
.map(
|
||||
(label) => Chip(
|
||||
label: Text(label),
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: theme.colorScheme.secondaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
labelStyle: TextStyle(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
return Text(
|
||||
key: const Key('player-subtitle-line'),
|
||||
partes.join(' · '),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality row (WU14: "quality row + Cambiar action" per the proposal's
|
||||
/// WU14 blast-radius line). **Design decision, not specified by any ADR**
|
||||
/// (WU14 has none): Radio Browser stations are one fixed stream each — this
|
||||
/// app has no per-station alternate-quality capability to invoke. Rather
|
||||
/// than a dead "Cambiar" button or an invented picker, it reconnects the
|
||||
/// current stream (the SAME `estado.reproducir(emisora)` call the error
|
||||
/// state's existing "Retry" button already uses) — a real, testable,
|
||||
/// zero-new-capability action, matching this branch's established
|
||||
/// "don't invent a capability absent from the domain" discipline (WU5's
|
||||
/// per-station artwork, WU9's dashed border).
|
||||
class _FilaCalidad extends StatelessWidget {
|
||||
const _FilaCalidad({required this.estado, required this.emisora});
|
||||
|
||||
final EstadoRadio estado;
|
||||
final Emisora emisora;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
|
||||
return PluriGlassSurface(
|
||||
key: const Key('player-quality-row'),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.hd_rounded, size: 20, color: tokens.liveGreen),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_codecInfo(context, emisora),
|
||||
style: theme.textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('player-quality-change-action'),
|
||||
onPressed: () => estado.reproducir(emisora),
|
||||
child: Text(l10n.playerQualityChangeAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _codecInfo(BuildContext context, Emisora e) {
|
||||
final parts = <String>[];
|
||||
if (e.codec != null) parts.add(e.codec!.toUpperCase());
|
||||
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
|
||||
return parts.isEmpty
|
||||
? AppLocalizations.of(context).qualityUnknown
|
||||
: AppLocalizations.of(context).qualityOriginal(parts.join(' · '));
|
||||
}
|
||||
|
||||
class _GrabacionWidget extends StatelessWidget {
|
||||
// Recording state lives in EstadoGrabacion (S4-R2); EstadoRadio no longer
|
||||
// notifies on recording progress, so this widget watches the new notifier.
|
||||
@@ -656,11 +689,20 @@ const _opciones = [
|
||||
_OpcionGrabacion(Duration(minutes: 30)),
|
||||
];
|
||||
|
||||
/// Transport row (WU14: favorite moved here from the AppBar; the old
|
||||
/// standalone live/not-playing dot is gone — the AppBar's status pill
|
||||
/// already covers that signal, so "favorite / stop / play-pause" is the
|
||||
/// full set — matching the proposal's own "3 controles" characterisation).
|
||||
class _Controles extends StatelessWidget {
|
||||
final EstadoRadio estado;
|
||||
final Emisora emisora;
|
||||
final bool esFavorito;
|
||||
|
||||
const _Controles({required this.estado, required this.emisora});
|
||||
const _Controles({
|
||||
required this.estado,
|
||||
required this.emisora,
|
||||
required this.esFavorito,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -709,8 +751,32 @@ class _Controles extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label:
|
||||
esFavorito
|
||||
? l10n.favoritesRemoveTooltip
|
||||
: l10n.favoritesAddTooltip,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
esFavorito
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_rounded,
|
||||
),
|
||||
iconSize: 28,
|
||||
color:
|
||||
esFavorito
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||
tooltip:
|
||||
esFavorito
|
||||
? l10n.favoritesRemoveTooltip
|
||||
: l10n.favoritesAddTooltip,
|
||||
onPressed: () => estado.toggleFavorito(emisora),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: l10n.stopPlaybackTooltip,
|
||||
@@ -726,7 +792,6 @@ class _Controles extends StatelessWidget {
|
||||
onPressed: cargando ? null : estado.detenerReproduccion,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 72,
|
||||
@@ -788,18 +853,6 @@ class _Controles extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Semantics(
|
||||
label: reproduciendo ? l10n.liveNow : l10n.notPlaying,
|
||||
child: Icon(
|
||||
Icons.fiber_manual_record_rounded,
|
||||
size: 32,
|
||||
color:
|
||||
reproduciendo
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -808,99 +861,259 @@ class _Controles extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TimerWidget extends StatelessWidget {
|
||||
final EstadoRadio estado;
|
||||
const _TimerWidget({required this.estado});
|
||||
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
|
||||
/// / Compartir), each opening its own bottom sheet (Compartir invokes
|
||||
/// directly instead — there is no sheet content for it).
|
||||
class _TileHerramienta extends StatelessWidget {
|
||||
const _TileHerramienta({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color? iconColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.pluriTokens;
|
||||
|
||||
if (!estado.timer.activo) {
|
||||
return TextButton.icon(
|
||||
icon: const Icon(Icons.bedtime_outlined, size: 18),
|
||||
label: Text(AppLocalizations.of(context).sleepTimer),
|
||||
onPressed: () => _mostrarTimerDialog(context),
|
||||
);
|
||||
}
|
||||
|
||||
return StreamBuilder<Duration>(
|
||||
stream: estado.timer.tiempoRestanteStream,
|
||||
builder: (context, snap) {
|
||||
final t = snap.data ?? Duration.zero;
|
||||
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
final label =
|
||||
t.inHours > 0
|
||||
? AppLocalizations.of(
|
||||
context,
|
||||
).durationHoursMinutesSeconds(t.inHours, m, s)
|
||||
: AppLocalizations.of(context).durationMinutesSeconds(m, s);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bedtime_rounded,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => estado.cancelarTimer(),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
child: Text(AppLocalizations.of(context).cancelAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _mostrarTimerDialog(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children:
|
||||
opcionesTimer
|
||||
.map(
|
||||
(min) => ActionChip(
|
||||
label: Text(
|
||||
AppLocalizations.of(
|
||||
ctx,
|
||||
).durationMinutesOnly(min),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimer(min);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.listSurface.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(tokens.radiusSm),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BandejaHerramientas extends StatelessWidget {
|
||||
const _BandejaHerramientas({
|
||||
required this.estado,
|
||||
required this.eq,
|
||||
required this.emisora,
|
||||
required this.compartir,
|
||||
});
|
||||
|
||||
final EstadoRadio estado;
|
||||
final EstadoEcualizador eq;
|
||||
final Emisora emisora;
|
||||
final Future<void> Function(String) compartir;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TileHerramienta(
|
||||
key: const Key('player-tool-eq'),
|
||||
icon: Icons.equalizer_rounded,
|
||||
label: l10n.playerToolEqLabel,
|
||||
onTap: () => _mostrarHojaEq(context, eq, emisora),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Consumer<EstadoGrabacion>(
|
||||
builder: (context, grabacion, _) {
|
||||
final activa = grabacion.estado.activa;
|
||||
return _TileHerramienta(
|
||||
key: const Key('player-tool-record'),
|
||||
icon:
|
||||
activa
|
||||
? Icons.fiber_manual_record_rounded
|
||||
: Icons.mic_rounded,
|
||||
iconColor: activa ? Theme.of(context).colorScheme.error : null,
|
||||
label: activa ? l10n.recordingActiveTitle : l10n.recordAction,
|
||||
onTap: () => _mostrarHojaGrabacion(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child:
|
||||
!estado.timer.activo
|
||||
? _TileHerramienta(
|
||||
key: const Key('player-tool-sleep'),
|
||||
icon: Icons.bedtime_outlined,
|
||||
label: l10n.sleepTimer,
|
||||
onTap: () => _mostrarHojaTimer(context, estado),
|
||||
)
|
||||
: StreamBuilder<Duration>(
|
||||
stream: estado.timer.tiempoRestanteStream,
|
||||
builder: (context, snap) {
|
||||
final t = snap.data ?? estado.timer.tiempoRestante;
|
||||
return _TileHerramienta(
|
||||
key: const Key('player-tool-sleep'),
|
||||
icon: Icons.bedtime_rounded,
|
||||
label: _formatearTiempoRestante(context, t),
|
||||
onTap: () => _mostrarHojaTimer(context, estado),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TileHerramienta(
|
||||
key: const Key('player-tool-share'),
|
||||
icon: Icons.share_rounded,
|
||||
label: l10n.recordingActionShare,
|
||||
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatearTiempoRestante(BuildContext context, Duration t) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return t.inHours > 0
|
||||
? l10n.durationHoursMinutesSeconds(t.inHours, m, s)
|
||||
: l10n.durationMinutesSeconds(m, s);
|
||||
}
|
||||
|
||||
/// Opens the per-station EQ sheet. Reuses [EcualizadorWidget] by its exact
|
||||
/// runtime type (design ADR-5, spec `eq-custom-presets` "Per-Station EQ
|
||||
/// Entry Point From the Player") — bound via the SAME per-station
|
||||
/// persistence path Settings' Ecualizador screen (WU13) uses, just against
|
||||
/// this station's uuid instead of `presetPrincipal`. Deliberately just the
|
||||
/// 5 sliders (no preset-chip row) — the spec scenario names "5 sliders",
|
||||
/// not a full preset picker for a single-station override.
|
||||
Future<void> _mostrarHojaEq(
|
||||
BuildContext context,
|
||||
EstadoEcualizador eq,
|
||||
Emisora emisora,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||
),
|
||||
child: EcualizadorWidget(
|
||||
key: const Key('player-eq-sheet-editor'),
|
||||
preset: eq.presetParaEmisora(emisora.uuid),
|
||||
habilitado: eq.activo,
|
||||
onCambio: (p) => eq.guardarPresetPorEmisora(emisora.uuid, p),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the recording sheet (WU14: relocates the old always-expanded
|
||||
/// `_GrabacionWidget` card behind the "Grabar" tool-tray tile — its OWN
|
||||
/// content/logic is unchanged, only its call site moves).
|
||||
Future<void> _mostrarHojaGrabacion(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
MediaQuery.viewInsetsOf(ctx).bottom + 24,
|
||||
),
|
||||
child: const _GrabacionWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the sleep-timer sheet (WU14: relocates the old standalone
|
||||
/// `_TimerWidget` button/inline-countdown behind the "Sleep timer"
|
||||
/// tool-tray tile). Adds a "Cancel timer" option at the top when a timer is
|
||||
/// already active — that capability existed inline before (the old
|
||||
/// countdown row's own Cancel button) and is preserved, not dropped.
|
||||
Future<void> _mostrarHojaTimer(BuildContext context, EstadoRadio estado) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(ctx).sleepTimer,
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (estado.timer.activo)
|
||||
ActionChip(
|
||||
key: const Key('player-sleep-cancel-action'),
|
||||
avatar: const Icon(Icons.close_rounded, size: 18),
|
||||
label: Text(l10n.cancelAction),
|
||||
onPressed: () {
|
||||
estado.cancelarTimer();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
for (final min in opcionesTimer)
|
||||
ActionChip(
|
||||
label: Text(
|
||||
AppLocalizations.of(ctx).durationMinutesOnly(min),
|
||||
),
|
||||
onPressed: () {
|
||||
estado.iniciarTimer(min);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user