From 65fbeb0287226933189b8e162a8c218f8689abb4 Mon Sep 17 00:00:00 2001 From: songbirdzz <165868972+songbirdzz@users.noreply.github.com> Date: Fri, 24 May 2024 10:01:07 -0700 Subject: [PATCH] Update: Optimize webview video resource obtaining --- lib/utils/webview.dart | 81 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/lib/utils/webview.dart b/lib/utils/webview.dart index dcf6c34..ff25aa9 100644 --- a/lib/utils/webview.dart +++ b/lib/utils/webview.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -6,15 +8,59 @@ import 'package:logger/logger.dart'; class Webview { static Future getVideoResourceUrl(String pageUrl) async { +/* + * So, in this method, we basically get video resources in 2 ways: + * 1. Use user script to check whether there's a video element in the html, + * and get its src attribute. However, this method cannot obtain video resource + * when the src is in the blob form. + * 2. Listen to network requests. However, due to iframe cross-domain restraints, + * we cannot intercept requests made in iframes, and that's how a lot of websites + * do to their players. Therefore we see the whole document as a tree and traverse + * it by setting href (directly fetch the urls may cause authentication issues, such + * as referers or cookies). + */ String? videoResourceUrl; final completer = Completer(); - _Node currentNode = _Node(pageUrl); + _Node currentNode = _Node(WebUri(pageUrl)); List history = []; + Future.delayed( + const Duration(seconds: 60), () => completer.complete(false)); final webview = HeadlessInAppWebView( initialUrlRequest: URLRequest(url: WebUri(pageUrl)), + initialUserScripts: UnmodifiableListView([ + UserScript(source: """ + setInterval(() => { + if (document.querySelector('video') !== null && document.querySelector('video').attributes.src.textContent.startsWith('http')) { + console.log(`VIDEO:\${document.querySelector('video').attributes.src.textContent}`); + // window.flutter_inappwebview.callHandler('foundVideoSrc', document.querySelector('video').attributes.src.textContent); + } + }, 100); + """, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START) + ]), onLoadStart: (controller, url) { Modular.get().i('Loading $url'); - history.add(url.toString()); + history.add(_removeQuery(url)); + }, + onConsoleMessage: (controller, consoleMessage) { + // Receive message from iframes + // We cannot use controller.addJavaScriptHandler here because + // window.flutter_inappwebview doesn't exist in cross-domain iframes + // Therefore we use onConsoleMessage to pass information, + // which is based on devtools API. + String message = jsonDecode(consoleMessage.message); + if (message.startsWith('VIDEO:')) { + Modular.get().i('Received console message: $consoleMessage'); + videoResourceUrl = message.substring('VIDEO:'.length); + completer.complete(true); + } + }, + onWebViewCreated: (controller) { + controller.addDevToolsProtocolEventListener( + eventName: 'responseReceived', + callback: (resp) { + Modular.get().i('Received resp: $resp'); + return resp; + }); }, onLoadResource: (controller, resource) { if (resource.url.toString().contains('.m3u8') || @@ -26,32 +72,35 @@ class Webview { // For Windows webviews (and maybe macOS as well), onLoadResource // can't capture requests sent within iframes. Therefore we store // and them for later use. - currentNode.children - .add(_Node(resource.url.toString(), parent: currentNode)); + currentNode.children.add(_Node(resource.url!, parent: currentNode)); } }, onLoadStop: (controller, url) async { if (completer.isCompleted) return; - // This timer seems redundant, however I do have seen some onLoadResource - // calls after onLoadStop. So we use a timer here to ensure maxium compatibility - + // Sometimes resource loading requests are still being made after + // onLoadStop. Therefore we delay a little bit here to make sure + // we won't miss any (or at least won't miss a lot) requests + await Future.delayed(const Duration(seconds: 2)); while (!currentNode.isRoot && - currentNode.children - .where((node) => !history.contains(node.url)) + currentNode.children.reversed + .where((node) => !history.contains(_removeQuery(node.url))) .isEmpty) { await controller.evaluateJavascript( source: "window.location.href='${currentNode.parent!.url}';"); currentNode = currentNode.parent!; } - if (currentNode.children - .where((node) => !history.contains(node.url)) + if (currentNode.children.reversed + .where((node) => !history.contains(_removeQuery(node.url))) .isNotEmpty) { - final node = currentNode.children - .where((node) => !history.contains(node.url)) + final node = currentNode.children.reversed + .where((node) => !history.contains(_removeQuery(node.url))) .first; currentNode = node; await controller.evaluateJavascript( source: "window.location.href='${node.url}';"); + } else { + // Root & no other subframes to go + completer.complete(false); } }, shouldAllowDeprecatedTLS: (controller, challenge) async => @@ -67,10 +116,14 @@ class Webview { Modular.get().i('Cannot find any video in $pageUrl'); return null; } + + static String _removeQuery(WebUri? url) { + return url!.origin + url.path; + } } class _Node { - final String url; + final WebUri url; final _Node? parent; final List<_Node> children = []; bool get isRoot => parent == null;