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'; import 'package:dlna_dart/dlna.dart'; import 'package:dlna_dart/xmlParser.dart'; 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; HttpServer? _mediaServer; bool get isCasting => _renderer != null && _mediaServer != null; Future initCast(BuildContext context, List entries) async { await stopCast(); final renderer = await _selectRenderer(context); _renderer = renderer; if (renderer == null) return; debugPrint('cast: select renderer `${renderer.info.friendlyName}` at ${renderer.info.URLBase}'); 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) { return Response.notFound('invalid 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'); } Future stopCast() async { if (isCasting) { debugPrint('cast: stop'); } await _mediaServer?.close(); _mediaServer = null; await _renderer?.stop(); _renderer = null; } Future _selectRenderer(BuildContext context) async { return await showDialog( context: context, builder: (context) => const CastDialog(), routeSettings: const RouteSettings(name: CastDialog.routeName), ); } Future castEntry(AvesEntry entry) async { final server = _mediaServer; final renderer = _renderer; if (server == null || renderer == null) return; try { debugPrint('cast: set entry=$entry'); await renderer.setUrl( '$_serverBaseUrl/${entry.id}', title: entry.bestTitle ?? '', type: entry.isVideo ? PlayType.Video : PlayType.Image, ); debugPrint('cast: play entry=$entry'); unawaited(renderer.play()); } catch (error, stack) { await reportService.recordError(error, stack); } } String? get _serverBaseUrl { 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; } }