Fix multi-device EQ auto-switching by calling obtenerDispositivoActual() during cargarPersistido() to seed the initial device ID. Add device management modal with rename support, EQ preset editing, and connection status indicator. Translate device UI keys to all 13 locales.
490 lines
15 KiB
Dart
490 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|
import 'package:pluriwave/modelos/dispositivo_audio.dart';
|
|
import 'package:pluriwave/modelos/emisora.dart';
|
|
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
import 'package:pluriwave/servicios/servicio_dispositivo_audio.dart';
|
|
import 'package:pluriwave/servicios/servicio_ecualizador.dart';
|
|
import 'package:pluriwave/servicios/servicio_favoritos.dart';
|
|
import 'package:pluriwave/servicios/servicio_radio.dart';
|
|
|
|
class FakeServicioAudio extends ServicioAudio {
|
|
FakeServicioAudio({this.ecualizadorActivo = true}) {
|
|
_estadoController.add(EstadoReproduccion.detenido);
|
|
}
|
|
|
|
final bool ecualizadorActivo;
|
|
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
|
|
final List<PresetEcualizador> presetsAplicados = [];
|
|
final List<Emisora> emisorasReproducidas = [];
|
|
final List<bool> cambiosEcualizadorActivo = [];
|
|
final List<double> volumenesAplicados = [];
|
|
int pausas = 0;
|
|
int configuracionesL10n = 0;
|
|
Emisora? _emisoraActual;
|
|
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
|
|
|
|
@override
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
// No global handler in tests; just record the call.
|
|
configuracionesL10n++;
|
|
}
|
|
|
|
@override
|
|
Emisora? get emisoraActual => _emisoraActual;
|
|
|
|
@override
|
|
bool get ecualizadorDisponible => ecualizadorActivo;
|
|
|
|
@override
|
|
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
|
|
|
@override
|
|
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
|
|
|
@override
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
_emisoraActual = emisora;
|
|
emisorasReproducidas.add(emisora);
|
|
emitirEstado(EstadoReproduccion.reproduciendo);
|
|
}
|
|
|
|
@override
|
|
Future<void> detener() async {
|
|
_emisoraActual = null;
|
|
emitirEstado(EstadoReproduccion.detenido);
|
|
}
|
|
|
|
@override
|
|
Future<void> pausar() async {
|
|
pausas++;
|
|
emitirEstado(EstadoReproduccion.pausado);
|
|
}
|
|
|
|
@override
|
|
Future<void> setVolumen(double vol) async {
|
|
volumenesAplicados.add(vol);
|
|
}
|
|
|
|
void emitirEstado(EstadoReproduccion estado) {
|
|
_estadoActual = estado;
|
|
_estadoController.add(estado);
|
|
}
|
|
|
|
@override
|
|
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
|
presetsAplicados.add(preset);
|
|
}
|
|
|
|
@override
|
|
Future<void> setBanda(int index, double db) async {}
|
|
|
|
@override
|
|
Future<void> setEcualizadorActivo(bool activo) async {
|
|
cambiosEcualizadorActivo.add(activo);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _estadoController.close();
|
|
}
|
|
}
|
|
|
|
class FakeServicioFavoritos extends ServicioFavoritos {
|
|
final List<Emisora> _favoritos = [];
|
|
final List<GrupoFavoritos> _grupos = [
|
|
const GrupoFavoritos(
|
|
id: GrupoFavoritos.sinAsignarId,
|
|
nombre: 'Sin asignar',
|
|
orden: 0,
|
|
protegido: true,
|
|
),
|
|
];
|
|
int toggleCalls = 0;
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerTodos() async =>
|
|
_favoritos.map((e) => e.copyWith()).toList();
|
|
|
|
@override
|
|
Future<void> agregar(Emisora emisora) async {
|
|
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
|
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminar(String uuid) async {
|
|
_favoritos.removeWhere((e) => e.uuid == uuid);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
_favoritos[i] = _favoritos[i].copyWith(orden: i);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> esFavorito(String uuid) async =>
|
|
_favoritos.any((e) => e.uuid == uuid);
|
|
|
|
@override
|
|
Future<bool> toggleFavorito(Emisora emisora) async {
|
|
toggleCalls += 1;
|
|
if (_favoritos.any((e) => e.uuid == emisora.uuid)) {
|
|
await eliminar(emisora.uuid);
|
|
return false;
|
|
}
|
|
await agregar(emisora);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<List<GrupoFavoritos>> obtenerGrupos() async =>
|
|
List.unmodifiable(_grupos);
|
|
|
|
@override
|
|
Future<GrupoFavoritos> crearGrupo(String nombre) async {
|
|
final grupo = GrupoFavoritos(
|
|
id: 'grupo_${_grupos.length}',
|
|
nombre: nombre.trim(),
|
|
orden: _grupos.length,
|
|
);
|
|
_grupos.add(grupo);
|
|
return grupo;
|
|
}
|
|
|
|
@override
|
|
Future<void> renombrarGrupo(String id, String nombre) async {
|
|
final index = _grupos.indexWhere((g) => g.id == id && !g.protegido);
|
|
if (index == -1) return;
|
|
_grupos[index] = _grupos[index].copyWith(nombre: nombre.trim());
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminarGrupo(String id) async {
|
|
if (id == GrupoFavoritos.sinAsignarId) return;
|
|
_grupos.removeWhere((g) => g.id == id && !g.protegido);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
if (_favoritos[i].grupoFavoritosId == id) {
|
|
_favoritos[i] = _favoritos[i].copyWith(
|
|
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> asignarGrupo(String uuid, String grupoId) async {
|
|
final destino =
|
|
_grupos.any((g) => g.id == grupoId)
|
|
? grupoId
|
|
: GrupoFavoritos.sinAsignarId;
|
|
final index = _favoritos.indexWhere((e) => e.uuid == uuid);
|
|
if (index != -1) {
|
|
_favoritos[index] = _favoritos[index].copyWith(grupoFavoritosId: destino);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> reordenar(String uuid, int nuevoOrden) async {
|
|
final oldIndex = _favoritos.indexWhere((e) => e.uuid == uuid);
|
|
if (oldIndex == -1 || _favoritos.isEmpty) return;
|
|
final targetIndex = nuevoOrden.clamp(0, _favoritos.length - 1);
|
|
final moved = _favoritos.removeAt(oldIndex);
|
|
_favoritos.insert(targetIndex, moved);
|
|
for (var i = 0; i < _favoritos.length; i++) {
|
|
_favoritos[i] = _favoritos[i].copyWith(orden: i);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeServicioRadio extends ServicioRadio {
|
|
FakeServicioRadio({
|
|
List<Emisora>? populares,
|
|
List<Emisora>? tendencias,
|
|
List<Emisora>? busqueda,
|
|
List<List<Emisora>>? popularesPorLlamada,
|
|
List<List<Emisora>>? tendenciasPorLlamada,
|
|
List<Object>? erroresPopularesPorLlamada,
|
|
List<Object>? erroresTendenciasPorLlamada,
|
|
}) : _populares = populares ?? [],
|
|
_tendencias = tendencias ?? [],
|
|
_busqueda = busqueda ?? [],
|
|
_popularesPorLlamada = popularesPorLlamada ?? const [],
|
|
_tendenciasPorLlamada = tendenciasPorLlamada ?? const [],
|
|
_erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [],
|
|
_erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const [];
|
|
|
|
final List<Emisora> _populares;
|
|
final List<Emisora> _tendencias;
|
|
final List<Emisora> _busqueda;
|
|
final List<List<Emisora>> _popularesPorLlamada;
|
|
final List<List<Emisora>> _tendenciasPorLlamada;
|
|
final List<Object> _erroresPopularesPorLlamada;
|
|
final List<Object> _erroresTendenciasPorLlamada;
|
|
|
|
int obtenerPopularesCalls = 0;
|
|
int obtenerTendenciasCalls = 0;
|
|
int registrarClickCalls = 0;
|
|
String? ultimoUuidClick;
|
|
|
|
Exception _normalizarError(Object error) =>
|
|
error is Exception ? error : Exception(error.toString());
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerPopulares({
|
|
int limit = 30,
|
|
int offset = 0,
|
|
}) async {
|
|
final llamada = obtenerPopularesCalls++;
|
|
if (llamada < _erroresPopularesPorLlamada.length) {
|
|
throw _normalizarError(_erroresPopularesPorLlamada[llamada]);
|
|
}
|
|
final data =
|
|
llamada < _popularesPorLlamada.length
|
|
? _popularesPorLlamada[llamada]
|
|
: _populares;
|
|
return data.take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async {
|
|
final llamada = obtenerTendenciasCalls++;
|
|
if (llamada < _erroresTendenciasPorLlamada.length) {
|
|
throw _normalizarError(_erroresTendenciasPorLlamada[llamada]);
|
|
}
|
|
final data =
|
|
llamada < _tendenciasPorLlamada.length
|
|
? _tendenciasPorLlamada[llamada]
|
|
: _tendencias;
|
|
return data.take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> buscar({
|
|
String? nombre,
|
|
String? pais,
|
|
String? idioma,
|
|
String? tag,
|
|
int limit = 30,
|
|
int offset = 0,
|
|
}) async {
|
|
return _busqueda.skip(offset).take(limit).toList();
|
|
}
|
|
|
|
@override
|
|
Future<void> registrarClick(String uuid) async {
|
|
registrarClickCalls += 1;
|
|
ultimoUuidClick = uuid;
|
|
}
|
|
}
|
|
|
|
class FakeServicioEcualizador extends ServicioEcualizador {
|
|
FakeServicioEcualizador({
|
|
PresetEcualizador? principal,
|
|
Map<String, PresetEcualizador>? porEmisora,
|
|
bool activo = true,
|
|
bool eqMultiDeviceEnabled = false,
|
|
Map<String, PresetEcualizador>? presetsDispositivo,
|
|
Map<String, PresetEcualizador>? presetsMatriz,
|
|
Map<String, String>? nombresDispositivos,
|
|
}) : _config = ConfiguracionEcualizador(
|
|
principal: principal ?? PresetEcualizador.flat,
|
|
porEmisora: porEmisora ?? {},
|
|
activo: activo,
|
|
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
|
presetsDispositivo: presetsDispositivo ?? {},
|
|
presetsMatriz: presetsMatriz ?? {},
|
|
nombresDispositivos: nombresDispositivos ?? {},
|
|
);
|
|
|
|
ConfiguracionEcualizador _config;
|
|
ConfiguracionEcualizador get config => _config;
|
|
|
|
@override
|
|
Future<ConfiguracionEcualizador> cargar() async => _config;
|
|
|
|
@override
|
|
Future<void> guardarPrincipal(PresetEcualizador preset) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: preset,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarActivo(bool activo) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
|
|
mapa[uuid] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: mapa,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> eliminarPorEmisora(String uuid) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
|
|
mapa.remove(uuid);
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: mapa,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: habilitado,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPresetDispositivo(
|
|
String deviceId,
|
|
PresetEcualizador preset,
|
|
) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(
|
|
_config.presetsDispositivo,
|
|
);
|
|
mapa[deviceId] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: mapa,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarPresetMatriz(
|
|
String clave,
|
|
PresetEcualizador preset,
|
|
) async {
|
|
final mapa = Map<String, PresetEcualizador>.from(_config.presetsMatriz);
|
|
mapa[clave] = preset;
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: mapa,
|
|
nombresDispositivos: _config.nombresDispositivos,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
|
|
_config = ConfiguracionEcualizador(
|
|
principal: _config.principal,
|
|
porEmisora: _config.porEmisora,
|
|
activo: _config.activo,
|
|
eqMultiDeviceEnabled: _config.eqMultiDeviceEnabled,
|
|
presetsDispositivo: _config.presetsDispositivo,
|
|
presetsMatriz: _config.presetsMatriz,
|
|
nombresDispositivos: nombres,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A [ServicioDispositivoAudio] fake that throws on [obtenerDispositivoActual].
|
|
///
|
|
/// Used to test the graceful-failure path in [EstadoEcualizador.cargarPersistido].
|
|
class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|
final _controller = StreamController<DispositivoAudio>.broadcast();
|
|
|
|
@override
|
|
DispositivoAudio? get dispositivoActual => null;
|
|
|
|
@override
|
|
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
|
|
|
@override
|
|
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
|
throw Exception('Platform channel error: device unavailable');
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _controller.close();
|
|
}
|
|
}
|
|
|
|
/// Fake implementation of [ServicioDispositivoAudio] for unit tests.
|
|
///
|
|
/// Use [emitirDispositivo] to push device change events without needing
|
|
/// a real platform channel.
|
|
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
|
final _controller = StreamController<DispositivoAudio>.broadcast();
|
|
DispositivoAudio? _dispositivoActual;
|
|
|
|
@override
|
|
DispositivoAudio? get dispositivoActual => _dispositivoActual;
|
|
|
|
@override
|
|
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
|
|
|
@override
|
|
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
|
return _dispositivoActual ??
|
|
const DispositivoAudio(
|
|
id: 'builtin_speaker',
|
|
tipo: TipoDispositivo.altavozInterno,
|
|
nombre: 'Speaker',
|
|
);
|
|
}
|
|
|
|
/// Helper for tests: push a device change event.
|
|
void emitirDispositivo(DispositivoAudio dispositivo) {
|
|
_dispositivoActual = dispositivo;
|
|
_controller.add(dispositivo);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await _controller.close();
|
|
}
|
|
}
|
|
|
|
Emisora emisoraDemo({
|
|
required String uuid,
|
|
required String nombre,
|
|
String url = 'https://stream.demo/radio',
|
|
}) {
|
|
return Emisora(uuid: uuid, nombre: nombre, url: url);
|
|
}
|