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