366 lines
11 KiB
Dart
366 lines
11 KiB
Dart
// lib/widgets/cast/backends/dlna_cast_backend.dart
|
||
//
|
||
// Backend DLNA estratto dal vecchio CastController.
|
||
// Gestisce:
|
||
// - selezione renderer
|
||
// - avvio media server locale
|
||
// - cast di una entry
|
||
// - slideshow DLNA
|
||
// - next/prev manuali
|
||
// - heartbeat
|
||
// - stop DLNA
|
||
|
||
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:aves/model/entry/entry.dart';
|
||
import 'package:aves/services/cast/cast_server.dart';
|
||
import 'package:aves/widgets/cast/dlna.dart';
|
||
|
||
import '../cast_controller.dart';
|
||
|
||
class DlnaCastBackend extends CastBackendBase {
|
||
DlnaCastBackend(super.controller);
|
||
|
||
DLNADevice? _renderer;
|
||
HttpServer? _mediaServer;
|
||
|
||
Timer? _slideshowTimer;
|
||
Timer? _heartbeatTimer;
|
||
|
||
int _currentIndex = 0;
|
||
List<AvesEntry>? _entries;
|
||
|
||
// ------------------------------------------------------------
|
||
// API: set renderer dall’esterno (CastIconButton)
|
||
// ------------------------------------------------------------
|
||
void setRenderer(DLNADevice device) {
|
||
_renderer = device;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// FOTO/VIDEO → delay slideshow
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Duration? getNextDelay(AvesEntry entry) {
|
||
final isVideo = entry.mimeType?.startsWith('video') ?? false;
|
||
|
||
if (isVideo) {
|
||
// 🎬 VIDEO → nessun timer → aspettiamo STOPPED
|
||
return null;
|
||
}
|
||
|
||
// 📸 FOTO → timer 5 secondi
|
||
return const Duration(seconds: 5);
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// Prewarm media server
|
||
// ------------------------------------------------------------
|
||
Future<void> ensureMediaServerPrewarm() async {
|
||
await _ensureMediaServer();
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// START CAST (equivale a fullscreen DLNA)
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> startCast(List<AvesEntry> entries, int index) =>
|
||
startFullscreen(entries, index);
|
||
|
||
// ------------------------------------------------------------
|
||
// FULLSCREEN DLNA
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> startFullscreen(List<AvesEntry> entries, int index) async {
|
||
_entries = entries;
|
||
_currentIndex = index;
|
||
|
||
controller.castIconState.value = CastIconState.connecting;
|
||
controller.isCastingNotifier.value = true;
|
||
|
||
await _ensureRenderer();
|
||
await _waitForDlnaReady();
|
||
await _ensureMediaServer();
|
||
|
||
await _castEntry(entries[index]);
|
||
|
||
controller.castIconState.value = CastIconState.connected;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// SLIDESHOW DLNA (manuale)
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> startSlideshow(List<AvesEntry> entries, int index) async {
|
||
await startFullscreen(entries, index);
|
||
|
||
_slideshowTimer?.cancel();
|
||
_slideshowTimer = Timer.periodic(
|
||
const Duration(seconds: 5),
|
||
(_) => nextManual(),
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// QUEUE MANUALE DLNA
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> startManualQueue(List<AvesEntry> entries, int index) async {
|
||
_entries = entries;
|
||
_currentIndex = index;
|
||
|
||
controller.castIconState.value = CastIconState.connecting;
|
||
controller.isCastingNotifier.value = true;
|
||
|
||
await _ensureRenderer();
|
||
await _waitForDlnaReady();
|
||
await _ensureMediaServer();
|
||
|
||
await _castEntry(entries[index]);
|
||
|
||
controller.castIconState.value = CastIconState.connected;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// NEXT / PREV
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> nextManual() async {
|
||
if (_entries == null) return;
|
||
|
||
_currentIndex++;
|
||
if (_currentIndex >= _entries!.length) _currentIndex = 0;
|
||
|
||
await _castEntry(_entries![_currentIndex]);
|
||
}
|
||
|
||
@override
|
||
Future<void> prevManual() async {
|
||
if (_entries == null) return;
|
||
|
||
_currentIndex--;
|
||
if (_currentIndex < 0) _currentIndex = _entries!.length - 1;
|
||
|
||
await _castEntry(_entries![_currentIndex]);
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// STOP DLNA
|
||
// ------------------------------------------------------------
|
||
@override
|
||
Future<void> stopCast() async {
|
||
_slideshowTimer?.cancel();
|
||
_slideshowTimer = null;
|
||
|
||
_heartbeatTimer?.cancel();
|
||
_heartbeatTimer = null;
|
||
|
||
try {
|
||
await _renderer?.stop();
|
||
} catch (_) {}
|
||
|
||
controller.castIconState.value = CastIconState.idle;
|
||
controller.isCastingNotifier.value = false;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// CAST ENTRY DLNA
|
||
// ------------------------------------------------------------
|
||
Future<void> _castEntry(AvesEntry entry) async {
|
||
debugPrint('DLNA: castEntry() renderer=$_renderer entry=${entry.id}');
|
||
|
||
if (_renderer == null) {
|
||
debugPrint('DLNA: _castEntry chiamato senza renderer');
|
||
return;
|
||
}
|
||
|
||
await castServer.ensureStarted();
|
||
|
||
// URL corretto per DLNA: usa il media server DLNA locale
|
||
final url =
|
||
'http://${_mediaServer!.address.host}:${_mediaServer!.port}/${entry.id}';
|
||
debugPrint('DLNA: URL generato = $url');
|
||
debugPrint('DLNA: serving entry ${entry.id} at $url');
|
||
|
||
final bool isVideo = (entry.durationMillis ?? 0) > 0;
|
||
|
||
try {
|
||
await _renderer!.setUrl(
|
||
url,
|
||
title: entry.bestTitle ?? '',
|
||
type: isVideo ? PlayType.Video : PlayType.Image,
|
||
);
|
||
} catch (e) {
|
||
// Fallback Sony Bravia
|
||
if (_isSonyBravia) {
|
||
debugPrint('DLNA: fallback Sony Bravia, ritento senza metadata');
|
||
await _renderer!.setUrl(
|
||
url,
|
||
title: '',
|
||
type: PlayType.Image,
|
||
);
|
||
} else {
|
||
debugPrint('DLNA: errore setUrl → $e');
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await _renderer!.play();
|
||
} catch (e) {
|
||
debugPrint('DLNA: play non supportato → $e');
|
||
}
|
||
|
||
_startHeartbeat();
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// HEARTBEAT DLNA
|
||
// ------------------------------------------------------------
|
||
void _startHeartbeat() {
|
||
_heartbeatTimer?.cancel();
|
||
_heartbeatTimer = Timer.periodic(
|
||
const Duration(seconds: 5),
|
||
(_) async {
|
||
try {
|
||
await DLNAHttp.postSoap(
|
||
url: _renderer!.avTransportUrl,
|
||
serviceType: 'urn:schemas-upnp-org:service:AVTransport:1',
|
||
action: 'GetTransportInfo',
|
||
body: '''
|
||
<u:GetTransportInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||
<InstanceID>0</InstanceID>
|
||
</u:GetTransportInfo>
|
||
''',
|
||
);
|
||
} catch (_) {}
|
||
},
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// MEDIA SERVER LOCALE
|
||
// ------------------------------------------------------------
|
||
Future<void> _ensureMediaServer() async {
|
||
if (_mediaServer != null) return;
|
||
|
||
final interfaces = await NetworkInterface.list(
|
||
includeLoopback: false,
|
||
type: InternetAddressType.IPv4,
|
||
);
|
||
|
||
String? ip;
|
||
for (final iface in interfaces) {
|
||
for (final addr in iface.addresses) {
|
||
if (!addr.isLoopback && addr.address.startsWith('192.168.')) {
|
||
ip = addr.address;
|
||
break;
|
||
}
|
||
}
|
||
if (ip != null) break;
|
||
}
|
||
|
||
if (ip == null) return;
|
||
|
||
_mediaServer = await HttpServer.bind(ip, 0, shared: true);
|
||
|
||
_mediaServer!.listen((req) async {
|
||
final id = int.tryParse(req.uri.path.substring(1));
|
||
if (id == null) {
|
||
req.response.statusCode = 404;
|
||
return req.response.close();
|
||
}
|
||
|
||
final entry = _entries?.firstWhere((e) => e.id == id);
|
||
if (entry == null) {
|
||
req.response.statusCode = 404;
|
||
return req.response.close();
|
||
}
|
||
|
||
final file = File(entry.path!);
|
||
if (!await file.exists()) {
|
||
req.response.statusCode = 404;
|
||
return req.response.close();
|
||
}
|
||
|
||
final length = await file.length();
|
||
final range = req.headers.value('range');
|
||
|
||
if (range == null) {
|
||
req.response.headers.set('Content-Type', entry.mimeType);
|
||
req.response.headers.set('Content-Length', '$length');
|
||
req.response.headers.set('Accept-Ranges', 'bytes');
|
||
await req.response.addStream(file.openRead());
|
||
return req.response.close();
|
||
}
|
||
|
||
final spec = range.substring('bytes='.length);
|
||
final parts = spec.split('-');
|
||
int start = int.tryParse(parts[0]) ?? 0;
|
||
int end =
|
||
parts.length > 1 ? int.tryParse(parts[1]) ?? length - 1 : length - 1;
|
||
|
||
start = start.clamp(0, length - 1);
|
||
end = end.clamp(start, length - 1);
|
||
|
||
req.response.statusCode = 206;
|
||
req.response.headers.set('Content-Type', entry.mimeType);
|
||
req.response.headers.set('Content-Length', '${end - start + 1}');
|
||
req.response.headers
|
||
.set('Content-Range', 'bytes $start-$end/$length');
|
||
req.response.headers.set('Accept-Ranges', 'bytes');
|
||
|
||
await req.response.addStream(file.openRead(start, end + 1));
|
||
return req.response.close();
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// RENDERER READY
|
||
// ------------------------------------------------------------
|
||
Future<void> _waitForDlnaReady() async {
|
||
if (_renderer == null) return;
|
||
|
||
for (int i = 0; i < 20; i++) {
|
||
try {
|
||
final resp = await DLNAHttp.postSoap(
|
||
url: _renderer!.avTransportUrl,
|
||
serviceType: 'urn:schemas-upnp-org:service:AVTransport:1',
|
||
action: 'GetTransportInfo',
|
||
body: '''
|
||
<u:GetTransportInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||
<InstanceID>0</InstanceID>
|
||
</u:GetTransportInfo>
|
||
''',
|
||
);
|
||
|
||
final body = resp.body;
|
||
|
||
if (body.contains('STOPPED') ||
|
||
body.contains('NO_MEDIA_PRESENT')) {
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// HELPERS
|
||
// ------------------------------------------------------------
|
||
bool get _isSonyBravia {
|
||
final name = _renderer?.info.friendlyName.toLowerCase() ?? '';
|
||
return name.contains('bravia') || name.contains('sony');
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// SELEZIONE RENDERER DLNA
|
||
// ------------------------------------------------------------
|
||
Future<void> _ensureRenderer() async {
|
||
if (_renderer != null) return;
|
||
debugPrint('DLNA: _ensureRenderer chiamato senza renderer (setRenderer non ancora usato)');
|
||
}
|
||
}
|