Files
pluriwave/lib/servicios/servicio_grabacion_radio.dart
T
FreeTLab 089b8b4227
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
fix(i18n): normalize translations and fallbacks
2026-06-03 21:20:08 +02:00

355 lines
10 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
enum EstadoGrabacionRadioTipo {
inactiva,
preparando,
grabando,
deteniendo,
error,
}
class EstadoGrabacionRadio {
const EstadoGrabacionRadio({
required this.tipo,
this.emisora,
this.archivo,
this.bytes = 0,
this.inicio,
this.duracionObjetivo,
this.error,
});
const EstadoGrabacionRadio.inactiva()
: tipo = EstadoGrabacionRadioTipo.inactiva,
emisora = null,
archivo = null,
bytes = 0,
inicio = null,
duracionObjetivo = null,
error = null;
final EstadoGrabacionRadioTipo tipo;
final Emisora? emisora;
final File? archivo;
final int bytes;
final DateTime? inicio;
final Duration? duracionObjetivo;
final String? error;
bool get activa =>
tipo == EstadoGrabacionRadioTipo.preparando ||
tipo == EstadoGrabacionRadioTipo.grabando ||
tipo == EstadoGrabacionRadioTipo.deteniendo;
Duration get transcurrido {
final inicioLocal = inicio;
if (inicioLocal == null) return Duration.zero;
return DateTime.now().difference(inicioLocal);
}
EstadoGrabacionRadio copyWith({
EstadoGrabacionRadioTipo? tipo,
Emisora? emisora,
File? archivo,
int? bytes,
DateTime? inicio,
Duration? duracionObjetivo,
String? error,
}) {
return EstadoGrabacionRadio(
tipo: tipo ?? this.tipo,
emisora: emisora ?? this.emisora,
archivo: archivo ?? this.archivo,
bytes: bytes ?? this.bytes,
inicio: inicio ?? this.inicio,
duracionObjetivo: duracionObjetivo ?? this.duracionObjetivo,
error: error,
);
}
}
class ServicioGrabacionRadio {
ServicioGrabacionRadio({
http.Client? cliente,
Future<Directory> Function()? resolverDirectorioBase,
DateTime Function()? reloj,
}) : _clienteExterno = cliente,
_resolverDirectorioBase = resolverDirectorioBase,
_reloj = reloj ?? DateTime.now;
static const _claveDirectorio = 'grabacion_radio_directorio';
static const _claveMaxBytes = 'grabacion_radio_max_bytes_v1';
static const int maxBytesPorDefecto = 500 * 1024 * 1024;
final http.Client? _clienteExterno;
final Future<Directory> Function()? _resolverDirectorioBase;
final DateTime Function() _reloj;
final _estadoController = StreamController<EstadoGrabacionRadio>.broadcast();
AppLocalizations? _l10n;
EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva();
StreamSubscription<List<int>>? _subscripcionStream;
IOSink? _sink;
http.Client? _clienteActivo;
Timer? _timerAutoStop;
String? _directorioConfigurado;
int _maxBytes = maxBytesPorDefecto;
File? _ultimoArchivo;
EstadoGrabacionRadio get estado => _estado;
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
String? get directorioConfigurado => _directorioConfigurado;
int get maxBytes => _maxBytes;
File? get ultimoArchivo => _ultimoArchivo;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
}
Future<void> inicializar() async {
try {
final prefs = await SharedPreferences.getInstance();
_directorioConfigurado = prefs.getString(_claveDirectorio);
_maxBytes = prefs.getInt(_claveMaxBytes) ?? maxBytesPorDefecto;
} catch (_) {
_directorioConfigurado = null;
_maxBytes = maxBytesPorDefecto;
}
}
Future<String> directorioEfectivo() async {
final configurado = _directorioConfigurado;
if (configurado != null && configurado.trim().isNotEmpty) {
return configurado;
}
final base =
_resolverDirectorioBase != null
? await _resolverDirectorioBase()
: await getApplicationDocumentsDirectory();
return '${base.path}${Platform.pathSeparator}grabaciones';
}
Future<void> guardarDirectorio(String path) async {
final normalizado = path.trim();
if (normalizado.isEmpty) {
throw ArgumentError(_textos.recordingPathEmptyError);
}
_directorioConfigurado = normalizado;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_claveDirectorio, normalizado);
} catch (_) {}
_emitir(_estado);
}
Future<void> limpiarDirectorioConfigurado() async {
_directorioConfigurado = null;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_claveDirectorio);
} catch (_) {}
_emitir(_estado);
}
Future<void> guardarMaxBytes(int bytes) async {
if (bytes <= 0) {
throw ArgumentError(_textos.recordingMaxSizeInvalidError);
}
_maxBytes = bytes;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_claveMaxBytes, bytes);
} catch (_) {}
_emitir(_estado);
}
Future<void> iniciar(
Emisora emisora, {
Duration? duracion,
String? directorio,
}) async {
if (_estado.activa) {
throw StateError(_textos.recordingAlreadyActiveError);
}
final inicio = _reloj();
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.preparando,
emisora: emisora,
inicio: inicio,
duracionObjetivo: duracion,
),
);
try {
final carpeta = Directory(directorio ?? await directorioEfectivo());
await carpeta.create(recursive: true);
final cliente = _clienteExterno ?? http.Client();
_clienteActivo = cliente;
final request = http.Request('GET', Uri.parse(emisora.url))
..headers['User-Agent'] = 'PluriWave/0.1.0 (radio recorder)';
final response = await cliente.send(request);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException('HTTP ${response.statusCode}', uri: request.url);
}
final archivo = File(
'${carpeta.path}${Platform.pathSeparator}'
'${_nombreArchivo(emisora, inicio, response.headers)}',
);
_sink = archivo.openWrite();
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.grabando,
emisora: emisora,
archivo: archivo,
inicio: inicio,
duracionObjetivo: duracion,
),
);
if (duracion != null && duracion > Duration.zero) {
_timerAutoStop = Timer(duracion, () => unawaited(detener()));
}
_subscripcionStream = response.stream.listen(
(chunk) {
_sink?.add(chunk);
final nuevosBytes = _estado.bytes + chunk.length;
_emitir(_estado.copyWith(bytes: nuevosBytes));
if (nuevosBytes >= _maxBytes) {
unawaited(detener());
}
},
onDone: () => unawaited(_finalizar()),
onError: (Object error) => unawaited(_fallar(error)),
cancelOnError: true,
);
} catch (error) {
await _fallar(error);
rethrow;
}
}
Future<void> detener() async {
if (!_estado.activa) return;
_emitir(_estado.copyWith(tipo: EstadoGrabacionRadioTipo.deteniendo));
_timerAutoStop?.cancel();
_timerAutoStop = null;
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _finalizar();
}
Future<void> _finalizar() async {
final archivoFinalizado = _estado.archivo;
_timerAutoStop?.cancel();
_timerAutoStop = null;
await _subscripcionStream?.cancel();
_subscripcionStream = null;
await _sink?.flush();
await _sink?.close();
_sink = null;
if (_clienteExterno == null) {
_clienteActivo?.close();
}
_clienteActivo = null;
if (archivoFinalizado != null) {
_ultimoArchivo = archivoFinalizado;
}
_emitir(const EstadoGrabacionRadio.inactiva());
}
Future<void> _fallar(Object error) async {
_timerAutoStop?.cancel();
_timerAutoStop = null;
await _subscripcionStream?.cancel();
_subscripcionStream = null;
try {
await _sink?.flush();
await _sink?.close();
} catch (_) {}
_sink = null;
if (_clienteExterno == null) {
_clienteActivo?.close();
}
_clienteActivo = null;
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.error,
error: error.toString(),
),
);
}
void _emitir(EstadoGrabacionRadio estado) {
_estado = estado;
if (!_estadoController.isClosed) {
_estadoController.add(estado);
}
}
String _nombreArchivo(
Emisora emisora,
DateTime inicio,
Map<String, String> headers,
) {
final fecha = inicio
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final nombre = _slug(emisora.nombre);
final extension = _extension(emisora.codec, headers['content-type']);
return '$fecha-$nombre.$extension';
}
String _extension(String? codec, String? contentType) {
final c = codec?.toLowerCase().trim();
if (c == 'mp3' || c == 'mpeg') return 'mp3';
if (c == 'aac' || c == 'aac+' || c == 'heaac') return 'aac';
if (c == 'ogg' || c == 'opus') return 'ogg';
if (c == 'flac') return 'flac';
final type = contentType?.toLowerCase() ?? '';
if (type.contains('mpeg') || type.contains('mp3')) return 'mp3';
if (type.contains('aac')) return 'aac';
if (type.contains('ogg') || type.contains('opus')) return 'ogg';
if (type.contains('flac')) return 'flac';
return 'mp3';
}
String _slug(String value) {
final slug = value
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9áéíóúüñ]+', unicode: true), '-')
.replaceAll(RegExp('-+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');
return slug.isEmpty ? 'radio' : slug;
}
Future<void> dispose() async {
_timerAutoStop?.cancel();
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _sink?.close();
await _estadoController.close();
}
}