336 lines
9.5 KiB
Dart
336 lines
9.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.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();
|
|
|
|
EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva();
|
|
StreamSubscription<List<int>>? _subscripcionStream;
|
|
IOSink? _sink;
|
|
http.Client? _clienteActivo;
|
|
Timer? _timerAutoStop;
|
|
String? _directorioConfigurado;
|
|
int _maxBytes = maxBytesPorDefecto;
|
|
|
|
EstadoGrabacionRadio get estado => _estado;
|
|
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
|
|
String? get directorioConfigurado => _directorioConfigurado;
|
|
int get maxBytes => _maxBytes;
|
|
|
|
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('La ruta de grabación no puede estar vacía');
|
|
}
|
|
_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('El tamaño máximo debe ser mayor que cero');
|
|
}
|
|
_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('Ya hay una grabación en curso');
|
|
}
|
|
|
|
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 {
|
|
_timerAutoStop?.cancel();
|
|
_timerAutoStop = null;
|
|
await _subscripcionStream?.cancel();
|
|
_subscripcionStream = null;
|
|
await _sink?.flush();
|
|
await _sink?.close();
|
|
_sink = null;
|
|
if (_clienteExterno == null) {
|
|
_clienteActivo?.close();
|
|
}
|
|
_clienteActivo = null;
|
|
_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();
|
|
}
|
|
}
|