From 23bccca5381f15abd4b281c0e26640701e70fe75 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 27 Oct 2023 21:11:46 +0300 Subject: [PATCH] get sink supported mime types --- lib/ref/upnp.dart | 38 +++++++++ lib/widgets/dialogs/cast_dialog.dart | 8 +- lib/widgets/viewer/controls/cast.dart | 117 +++++++++++++++++++++----- 3 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 lib/ref/upnp.dart diff --git a/lib/ref/upnp.dart b/lib/ref/upnp.dart new file mode 100644 index 000000000..c6ee0337f --- /dev/null +++ b/lib/ref/upnp.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class Upnp { + static const String upnpDeviceTypeMediaRenderer = 'urn:schemas-upnp-org:device:MediaRenderer:1'; + static const String upnpServiceTypeConnectionManager = 'urn:schemas-upnp-org:service:ConnectionManager:1'; + + static String getProtocolInfoActionXml() { + return ''' + + + + 0 + + +'''; + } +} + +class UpnpProtocolInfo { + late final Set entries; + + UpnpProtocolInfo(String text) { + entries = text.split(',').where((v) => v.isNotEmpty).map(UpnpProtocolInfoEntry.new).toSet(); + } +} + +@immutable +class UpnpProtocolInfoEntry { + late final String protocol, network, contentFormat, additionalInfo; + + UpnpProtocolInfoEntry(String text) { + final parts = text.split(':'); + protocol = parts[0]; + network = parts[1]; + contentFormat = parts[2]; + additionalInfo = parts[3]; + } +} diff --git a/lib/widgets/dialogs/cast_dialog.dart b/lib/widgets/dialogs/cast_dialog.dart index 4ac33b993..3b7ba6058 100644 --- a/lib/widgets/dialogs/cast_dialog.dart +++ b/lib/widgets/dialogs/cast_dialog.dart @@ -1,3 +1,4 @@ +import 'package:aves/ref/upnp.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:dlna_dart/dlna.dart'; @@ -16,15 +17,16 @@ class _CastDialogState extends State { final DLNAManager _dlnaManager = DLNAManager(); final Map _seenRenderers = {}; - static const String upnpDeviceTypeMediaRenderer = 'urn:schemas-upnp-org:device:MediaRenderer:1'; - @override void initState() { super.initState(); _dlnaManager.start().then((deviceManager) { deviceManager.devices.stream.listen((devices) { - _seenRenderers.addAll(Map.fromEntries(devices.entries.where((kv) => kv.value.info.deviceType == upnpDeviceTypeMediaRenderer))); + _seenRenderers.addAll(Map.fromEntries(devices.entries.where((kv) => kv.value.info.deviceType == Upnp.upnpDeviceTypeMediaRenderer).map((kv) { + final device = kv.value; + return MapEntry(device.info.friendlyName, device); + }))); setState(() {}); }); }); diff --git a/lib/widgets/viewer/controls/cast.dart b/lib/widgets/viewer/controls/cast.dart index d0219e6b4..731712a59 100644 --- a/lib/widgets/viewer/controls/cast.dart +++ b/lib/widgets/viewer/controls/cast.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; +import 'package:aves/ref/upnp.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/dialogs/cast_dialog.dart'; import 'package:collection/collection.dart'; @@ -12,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:network_info_plus/network_info_plus.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:xml/xml.dart'; mixin CastMixin { DLNADevice? _renderer; @@ -30,32 +33,33 @@ mixin CastMixin { final ip = await NetworkInfo().getWifiIP(); if (ip == null) return; + Set? supportedMimeTypes; + final handler = const Pipeline().addHandler((request) async { + debugPrint('cast: received request for id=${request.url}'); final id = int.tryParse(request.url.path); - if (id != null) { - final entry = entries.firstWhereOrNull((v) => v.id == id); - if (entry != null) { - final bytes = await mediaFetchService.getImage( - entry.uri, - entry.mimeType, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - pageId: entry.pageId, - sizeBytes: entry.sizeBytes, - ); - debugPrint('cast: send ${bytes.length} bytes for entry=$entry'); - return Response.ok( - bytes, - headers: { - 'Content-Type': entry.mimeType, - }, - ); - } + if (id == null) { + return Response.notFound('invalid url=${request.url}'); } - return Response.notFound('no resource for url=${request.url}'); + + final entry = entries.firstWhereOrNull((v) => v.id == id); + if (entry == null) { + return Response.notFound('no resource for url=${request.url}'); + } + + if (supportedMimeTypes == null) { + // do not call `GetProtocolInfo` before serving files, + // as it somehow makes `Play` time out (but not `SetAVTransportURI`) + supportedMimeTypes = await renderer.getSinkSupportedMimeTypes(); + debugPrint('cast: supported MIME types=$supportedMimeTypes'); + } + + // TODO TLAD [cast] transcode when MIME type is not supported by renderer + + return await _sendEntry(entry); }); _mediaServer = await shelf_io.serve(handler, ip, 8080); - debugPrint('cast: serving media on $_serverBaseUrl}'); + debugPrint('cast: serving media on $_serverBaseUrl'); } Future stopCast() async { @@ -83,14 +87,15 @@ mixin CastMixin { final renderer = _renderer; if (server == null || renderer == null) return; - debugPrint('cast: set entry=$entry'); try { + debugPrint('cast: set entry=$entry'); await renderer.setUrl( '$_serverBaseUrl/${entry.id}', title: entry.bestTitle ?? '', type: entry.isVideo ? PlayType.Video : PlayType.Image, ); - await renderer.play(); + debugPrint('cast: play entry=$entry'); + unawaited(renderer.play()); } catch (error, stack) { await reportService.recordError(error, stack); } @@ -100,4 +105,70 @@ mixin CastMixin { final server = _mediaServer; return server != null ? 'http://${server.address.host}:${server.port}' : null; } + + Future _sendEntry(AvesEntry entry) async { + // TODO TLAD [cast] providing downscaled versions is suitable when properly serving with `MediaServer`, as the renderer can pick what is best + final bytes = await mediaFetchService.getImage( + entry.uri, + entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + pageId: entry.pageId, + sizeBytes: entry.sizeBytes, + ); + + debugPrint('cast: send ${bytes.length} bytes for entry=$entry'); + return Response.ok( + bytes, + headers: { + 'Content-Type': entry.mimeType, + }, + ); + } +} + +extension DLNADeviceExtra on DLNADevice { + Future requestCustom({ + required String serviceId, + required String serviceType, + required String action, + required String data, + }) async { + return DLNAHttp.post( + Uri.parse(controlURL(serviceId)), + Map.from({ + 'SOAPAction': '"$serviceType#$action"', + 'Content-Type': 'text/xml', + }), + const Utf8Encoder().convert(data), + ); + } + + Future> getProtocolInfo() async { + final result = await requestCustom( + serviceId: 'ConnectionManager', + serviceType: Upnp.upnpServiceTypeConnectionManager, + action: 'GetProtocolInfo', + data: Upnp.getProtocolInfoActionXml(), + ); + final doc = XmlDocument.parse(result); + final sink = UpnpProtocolInfo(doc.findAllElements('Sink').first.innerText); + final source = UpnpProtocolInfo(doc.findAllElements('Source').first.innerText); + return { + 'sink': sink, + 'source': source, + }; + } + + Future?> getSinkSupportedMimeTypes() async { + final sinkProtocolInfo = (await getProtocolInfo())['sink']; + if (sinkProtocolInfo != null) { + final byProtocol = groupBy(sinkProtocolInfo.entries, (v) => v.protocol); + final httpGet = byProtocol['http-get']; + if (httpGet != null) { + return httpGet.map((v) => v.contentFormat).toSet(); + } + } + return null; + } }