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 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 Function()? _resolverDirectorioBase; final DateTime Function() _reloj; final _estadoController = StreamController.broadcast(); AppLocalizations? _l10n; EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva(); StreamSubscription>? _subscripcionStream; IOSink? _sink; http.Client? _clienteActivo; Timer? _timerAutoStop; String? _directorioConfigurado; int _maxBytes = maxBytesPorDefecto; File? _ultimoArchivo; EstadoGrabacionRadio get estado => _estado; Stream 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 inicializar() async { try { final prefs = await SharedPreferences.getInstance(); _directorioConfigurado = prefs.getString(_claveDirectorio); _maxBytes = prefs.getInt(_claveMaxBytes) ?? maxBytesPorDefecto; } catch (_) { _directorioConfigurado = null; _maxBytes = maxBytesPorDefecto; } } Future 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 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 limpiarDirectorioConfigurado() async { _directorioConfigurado = null; try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_claveDirectorio); } catch (_) {} _emitir(_estado); } Future 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 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 detener() async { if (!_estado.activa) return; _emitir(_estado.copyWith(tipo: EstadoGrabacionRadioTipo.deteniendo)); _timerAutoStop?.cancel(); _timerAutoStop = null; _clienteActivo?.close(); await _subscripcionStream?.cancel(); await _finalizar(); } Future _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 _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 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 dispose() async { _timerAutoStop?.cancel(); _clienteActivo?.close(); await _subscripcionStream?.cancel(); await _sink?.close(); await _estadoController.close(); } }