import 'dart:async'; import 'dart:io'; import 'dart:ui' show Locale; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/archivo_grabacion.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_grabacion_radio.dart'; /// Recording state extracted from `EstadoRadio` (S4-R2). /// /// Owns [ServicioGrabacionRadio] and the recording-state subscription, and /// notifies ONLY its own listeners — recording progress must not rebuild /// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on /// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference /// to this notifier. /// Whether [emisora] is something the recorder can actually capture: a live /// network stream. /// /// The recorder opens the URL as an HTTP stream and writes the bytes to disk, /// so anything else fails inside the HTTP client with a message no user can /// act on ("Unsupported scheme 'content' in URI content://..."). /// /// This is not hypothetical tidiness. `PluriWaveAudioHandler._cambiarFuente` /// sets `emisoraActual` for EVERY source it plays, so a local MP3 shows up /// here as an `Emisora` whose `url` is the `content://` document URI it was /// opened from. Recording a local file makes no sense anyway — it is already /// on the device. bool esEmisoraGrabable(Emisora emisora) { final esquema = Uri.tryParse(emisora.url)?.scheme.toLowerCase(); return esquema == 'http' || esquema == 'https'; } class EstadoGrabacion extends ChangeNotifier { EstadoGrabacion({ ServicioGrabacionRadio? servicio, Emisora? Function()? emisoraActual, void Function(String mensaje)? alError, }) : servicio = servicio ?? ServicioGrabacionRadio(), _emisoraActual = emisoraActual ?? (() => null), _alError = alError { _suscripcion = this.servicio.estadoStream.listen((estado) { if (estado.tipo == EstadoGrabacionRadioTipo.error && estado.error != null) { _alError?.call(_textos.radioRecordingError(estado.error!)); } notifyListeners(); }); } static const MethodChannel _fileActionsChannel = MethodChannel( 'pluriwave/file_actions', ); final ServicioGrabacionRadio servicio; /// Callback into the owner (EstadoRadio) for the currently playing station; /// keeps this notifier free of any station-list coupling. final Emisora? Function() _emisoraActual; /// User-visible error sink (EstadoRadio routes it to its snackbar stream). final void Function(String mensaje)? _alError; StreamSubscription? _suscripcion; AppLocalizations? _l10n; AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; return lookupAppLocalizations(const Locale('es')); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; servicio.configurarLocalizaciones(l10n); } Future inicializar() => servicio.inicializar(); EstadoGrabacionRadio get estado => servicio.estado; bool get activa => servicio.estado.activa; String? get directorioConfigurado => servicio.directorioConfigurado; int get maxBytes => servicio.maxBytes; File? get ultimoArchivo => servicio.ultimoArchivo; Future iniciar({Duration? duracion}) async { final actual = _emisoraActual(); // `emisoraActual` is set by `_cambiarFuente` for EVERY source, local // tracks included -- a local file becomes an `Emisora` whose `url` is the // SAF `content://` URI it was opened from. Handing that to the recorder // produced "Unsupported scheme 'content' in URI content://..." on screen, // and it started happening only once local music playback existed: before // that, whatever was playing was always a real station. if (actual == null || !esEmisoraGrabable(actual)) { _alError?.call(_textos.recordingSelectStationFirst); return; } try { await servicio.iniciar(actual, duracion: duracion); } catch (e) { _alError?.call(_textos.recordingStartError(e.toString())); } } Future detener() => servicio.detener(); Future cambiarMaxBytes(int bytes) async { await servicio.guardarMaxBytes(bytes); notifyListeners(); } Future cambiarDirectorio(String path) async { await servicio.guardarDirectorio(path); notifyListeners(); } Future restaurarDirectorio() async { await servicio.limpiarDirectorioConfigurado(); notifyListeners(); } Future directorioEfectivo() => servicio.directorioEfectivo(); /// WU15, recordings-library: browsable listing of recording files /// already on disk. Thin delegate — no additional logic. Future> listarGrabaciones() => servicio.listarGrabaciones(); /// WU15: deletes [ruta] and notifies listeners so the library screen's /// row disappears. Future eliminarGrabacion(String ruta) async { await servicio.eliminarGrabacion(ruta); notifyListeners(); } /// WU15: renames the recording at [ruta] to [nuevoNombre] and notifies /// listeners so the library screen reflects the new name. Future renombrarGrabacion(String ruta, String nuevoNombre) async { await servicio.renombrarGrabacion(ruta, nuevoNombre); notifyListeners(); } Future abrirDirectorio() async { final ruta = await directorioEfectivo(); await Directory(ruta).create(recursive: true); if (!kIsWeb && Platform.isAndroid) { final abierto = await _fileActionsChannel.invokeMethod( 'viewDirectory', {'path': ruta}, ); return abierto ?? false; } final uri = Uri.directory(ruta); return launchUrl(uri, mode: LaunchMode.externalApplication); } Future abrirUltimaGrabacion() async { final archivo = ultimoArchivo; if (archivo == null || !await archivo.exists()) { debugPrint('[PluriWave][recordings] last recording missing'); return false; } debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}'); if (!kIsWeb && Platform.isAndroid) { final abierto = await _fileActionsChannel.invokeMethod('openFile', { 'path': archivo.path, 'mimeType': 'audio/*', }); return abierto ?? false; } return launchUrl( Uri.file(archivo.path), mode: LaunchMode.externalApplication, ); } @override void dispose() { _suscripcion?.cancel(); unawaited(servicio.dispose()); super.dispose(); } }