aves_mio100/lib/widgets/cast/backends/dlna_cast_backend.dart
2026-07-06 14:52:45 +02:00

366 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 dallesterno (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)');
}
}