Aves_78_remote_google_cast_.../lib/widgets/cast/dlna.dart
2026-06-11 14:12:14 +02:00

235 lines
6.8 KiB
Dart

// lib/widgets/cast/dlna.dart
//
// Implementazione DLNA minimale ma reale, pensata per funzionare
// con dlna_cast_controller.dart e upnp.dart, inclusi Sony Bravia.
//
// Espone:
// - DLNADevice
// - DLNAInfo
// - PlayType
// - DLNAHttp
//
// Richiede nel pubspec:
// http:
// xml:
//
// E usa:
// lib/ref/upnp.dart
//
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'package:aves/ref/upnp.dart';
@immutable
class DLNAInfo {
final String friendlyName;
const DLNAInfo({
required this.friendlyName,
});
}
enum PlayType {
Video,
Image,
}
class DLNAHttp {
const DLNAHttp._();
static Future<http.Response> postSoap({
required Uri url,
required String serviceType,
required String action,
required String body,
Duration timeout = const Duration(seconds: 5),
}) async {
final envelope = '''
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
$body
</s:Body>
</s:Envelope>
''';
final headers = <String, String>{
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPACTION': '"$serviceType#$action"',
'Connection': 'Close',
};
final resp = await http
.post(url, headers: headers, body: utf8.encode(envelope))
.timeout(timeout);
if (resp.statusCode >= 400) {
throw HttpException(
'DLNA SOAP error ${resp.statusCode} for $action on $url',
uri: url,
);
}
return resp;
}
}
class DLNADevice {
final Uri avTransportUrl;
final Uri connectionManagerUrl;
final DLNAInfo info;
DLNADevice({
required this.avTransportUrl,
required this.connectionManagerUrl,
required this.info,
});
//──────────────────────────────────────────────
// AVTransport: SetAVTransportURI
//──────────────────────────────────────────────
Future<void> setUrl(
String url, {
required String title,
required PlayType type,
}) async {
// Molti renderer (inclusi Sony Bravia) accettano metadata vuoto.
// Se servirà, si può estendere con DIDL-Lite completo.
final escapedUrl = _xmlEscape(url);
final body = '''
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>$escapedUrl</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
''';
await DLNAHttp.postSoap(
url: avTransportUrl,
serviceType: 'urn:schemas-upnp-org:service:AVTransport:1',
action: 'SetAVTransportURI',
body: body,
);
}
//──────────────────────────────────────────────
// AVTransport: Play
//──────────────────────────────────────────────
Future<void> play() async {
final body = '''
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
''';
await DLNAHttp.postSoap(
url: avTransportUrl,
serviceType: 'urn:schemas-upnp-org:service:AVTransport:1',
action: 'Play',
body: body,
);
}
//──────────────────────────────────────────────
// AVTransport: Stop
//──────────────────────────────────────────────
Future<void> stop() async {
final body = '''
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Stop>
''';
await DLNAHttp.postSoap(
url: avTransportUrl,
serviceType: 'urn:schemas-upnp-org:service:AVTransport:1',
action: 'Stop',
body: body,
);
}
//──────────────────────────────────────────────
// ConnectionManager: GetProtocolInfo
//──────────────────────────────────────────────
Future<Set<String>> getSinkSupportedMimeTypes() async {
final body = Upnp.getProtocolInfoActionXml();
final resp = await DLNAHttp.postSoap(
url: connectionManagerUrl,
serviceType: Upnp.upnpServiceTypeConnectionManager,
action: 'GetProtocolInfo',
body: _extractBody(body),
);
final text = resp.body;
final doc = XmlDocument.parse(text);
// Cerchiamo l'elemento che contiene il sink protocol info.
// I nomi variano tra device, quindi cerchiamo per contenuto.
final sinkNode = doc
.findAllElements('Sink')
.followedBy(doc.findAllElements('SinkProtocolInfo'))
.cast<XmlElement?>()
.firstWhere(
(e) => e != null && e.innerText.trim().isNotEmpty,
orElse: () => null,
);
if (sinkNode == null) {
return <String>{};
}
final sinkText = sinkNode.innerText.trim();
if (sinkText.isEmpty) {
return <String>{};
}
final protocolInfo = UpnpProtocolInfo(sinkText);
// Ritorniamo solo i MIME type (contentFormat).
return protocolInfo.entries.map((e) => e.contentFormat).toSet();
}
//──────────────────────────────────────────────
// Helper
//──────────────────────────────────────────────
static String _xmlEscape(String value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
// Upnp.getProtocolInfoActionXml() restituisce già un envelope completo.
// Qui estraiamo solo il body interno per riusarlo nel nostro envelope.
static String _extractBody(String envelope) {
try {
final doc = XmlDocument.parse(envelope);
final body = doc.findAllElements('s:Body').firstOrNull ??
doc.findAllElements('Body').first;
return body.children.map((n) => n.toXmlString()).join();
} catch (_) {
// In caso di problemi, usiamo l'XML così com'è.
return envelope;
}
}
}
extension _FirstOrNull<E> on Iterable<E> {
E? get firstOrNull {
final it = iterator;
if (!it.moveNext()) return null;
return it.current;
}
}