Files
pluriwave/lib/estado/estado_grabacion.dart
T
FreeTLab 54d87190fe fix(grabacion): reject a local track as a recording source
Reported, with this on screen:

  No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme
  'content' in URI content://com.android.externalstorage.documents/tree/
  primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3

The URI in that message is a local MP3, not a station.
PluriWaveAudioHandler._cambiarFuente sets `emisoraActual` for EVERY source
it plays, so a local track surfaces as an Emisora whose `url` is the SAF
content:// document URI it was opened from. EstadoGrabacion.iniciar only
checked for null, handed that straight to the recorder, and the HTTP
client failed with a message no user can act on.

"It used to work" is exactly right: before local music playback existed,
whatever was playing was always a real station, so the case could not
arise. The recorder never changed.

iniciar() now also requires a real network stream (esEmisoraGrabable) and
falls back to the existing "select a station first" message, which is the
correct guidance here -- recording a local file makes no sense anyway,
it is already on the device. No new l10n key, so no 13-locale churn for a
message that already says the right thing.

Tests: 1158 -> 1161.
2026-08-07 00:18:48 +02:00

188 lines
6.5 KiB
Dart

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<EstadoGrabacionRadio>? _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<void> 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<void> 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<void> detener() => servicio.detener();
Future<void> cambiarMaxBytes(int bytes) async {
await servicio.guardarMaxBytes(bytes);
notifyListeners();
}
Future<void> cambiarDirectorio(String path) async {
await servicio.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorio() async {
await servicio.limpiarDirectorioConfigurado();
notifyListeners();
}
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
/// WU15, recordings-library: browsable listing of recording files
/// already on disk. Thin delegate — no additional logic.
Future<List<ArchivoGrabacion>> listarGrabaciones() =>
servicio.listarGrabaciones();
/// WU15: deletes [ruta] and notifies listeners so the library screen's
/// row disappears.
Future<void> 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<void> renombrarGrabacion(String ruta, String nuevoNombre) async {
await servicio.renombrarGrabacion(ruta, nuevoNombre);
notifyListeners();
}
Future<bool> abrirDirectorio() async {
final ruta = await directorioEfectivo();
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
}
final uri = Uri.directory(ruta);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> 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<bool>('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();
}
}