feat: add LinkExtractionService with specialized support for hdfilmizle video extraction
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
@@ -8,26 +9,505 @@ abstract class ILinkExtractionService {
|
||||
}
|
||||
|
||||
class LinkExtractionService implements ILinkExtractionService {
|
||||
|
||||
|
||||
@override
|
||||
Future<String?> extractVideoUrl(String pageUrl) async {
|
||||
// 1. Zaten direkt bir video linkiyse hemen döndür
|
||||
if (_isVideoUrl(pageUrl)) return pageUrl;
|
||||
|
||||
// 2. hdfilmizle.so gibi bilinen siteleri özel olarak işle
|
||||
if (_isHdFilmIzle(pageUrl)) {
|
||||
debugPrint("EXTRACTION: Detected hdfilmizle.so — using specialized extractor");
|
||||
return _extractFromHdFilmIzle(pageUrl);
|
||||
}
|
||||
|
||||
// 3. Genel amaçlı sniffer (diğer siteler için)
|
||||
return _extractGeneric(pageUrl);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// hdfilmizle.so Özel Extractor
|
||||
// ============================================================
|
||||
|
||||
bool _isHdFilmIzle(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
return lower.contains('hdfilmizle.so') ||
|
||||
lower.contains('hdfilmizle.co') ||
|
||||
lower.contains('hdfilmizle.org') ||
|
||||
lower.contains('hdfilmizle.net');
|
||||
}
|
||||
|
||||
/// hdfilmizle.so için 2 aşamalı extraction:
|
||||
/// Aşama 1: Ana sayfadan "parts" array'ini çıkar → iframe URL al
|
||||
/// Aşama 2: iframe (vidrame.pro) sayfasından EE.dd() ile m3u8 URL decode et
|
||||
Future<String?> _extractFromHdFilmIzle(String pageUrl) async {
|
||||
// AŞAMA 1: Ana sayfadan iframe URL'lerini çıkar
|
||||
debugPrint("EXTRACTION [PHASE 1]: Loading main page to extract iframe URLs...");
|
||||
final iframeUrl = await _extractIframeUrl(pageUrl);
|
||||
|
||||
if (iframeUrl == null) {
|
||||
debugPrint("EXTRACTION [PHASE 1]: No iframe URL found, falling back to generic extractor");
|
||||
return _extractGeneric(pageUrl);
|
||||
}
|
||||
|
||||
debugPrint("EXTRACTION [PHASE 1]: Found iframe URL: $iframeUrl");
|
||||
|
||||
// AŞAMA 2: iframe sayfasından video URL'yi decode et
|
||||
debugPrint("EXTRACTION [PHASE 2]: Loading iframe to decode video URL...");
|
||||
final videoUrl = await _extractVideoFromIframe(iframeUrl);
|
||||
|
||||
if (videoUrl != null) {
|
||||
debugPrint("EXTRACTION [PHASE 2]: SUCCESS! Video URL: $videoUrl");
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
debugPrint("EXTRACTION [PHASE 2]: Failed, falling back to generic extractor");
|
||||
return _extractGeneric(pageUrl);
|
||||
}
|
||||
|
||||
/// Aşama 1: hdfilmizle.so ana sayfasından "parts" JavaScript değişkenini parse eder
|
||||
/// ve ilk iframe URL'sini döndürür.
|
||||
Future<String?> _extractIframeUrl(String pageUrl) async {
|
||||
final completer = Completer<String?>();
|
||||
HeadlessInAppWebView? webView;
|
||||
Timer? timeout;
|
||||
|
||||
void cleanup() {
|
||||
timeout?.cancel();
|
||||
webView?.dispose();
|
||||
webView = null;
|
||||
}
|
||||
|
||||
timeout = Timer(const Duration(seconds: 12), () {
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint("EXTRACTION [PHASE 1]: Timeout");
|
||||
cleanup();
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
// JS: "parts" array'ini bul ve iframe src'lerini çıkar
|
||||
final extractScript = UserScript(
|
||||
source: '''
|
||||
(function() {
|
||||
if (window.__phase1Injected) return;
|
||||
window.__phase1Injected = true;
|
||||
|
||||
// parts array'inden iframe URL çıkar
|
||||
window.__extractIframeUrls = function() {
|
||||
try {
|
||||
// 1. Sayfadaki tüm script taglarını tara
|
||||
var scripts = document.querySelectorAll('script');
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var text = scripts[i].textContent || scripts[i].innerText || '';
|
||||
|
||||
// "let parts" veya "var parts" değişkenini bul
|
||||
var partsMatch = text.match(/(?:let|var|const)\\s+parts\\s*=\\s*(\\[.*?\\]);/s);
|
||||
if (partsMatch) {
|
||||
try {
|
||||
var partsData = eval('(' + partsMatch[1] + ')');
|
||||
var iframeUrls = [];
|
||||
|
||||
for (var j = 0; j < partsData.length; j++) {
|
||||
var part = partsData[j];
|
||||
var data = part.data || part.html || '';
|
||||
|
||||
// iframe src'yi çıkar
|
||||
var iframeMatch = data.match(/src=["']([^"']+)["']/i);
|
||||
if (iframeMatch) {
|
||||
iframeUrls.push({
|
||||
title: part.title || part.name || ('Part ' + j),
|
||||
url: iframeMatch[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (iframeUrls.length > 0) {
|
||||
window.flutter_inappwebview.callHandler('iframeUrlsFound', JSON.stringify(iframeUrls));
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('PHASE1: Error parsing parts: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: Sayfadaki mevcut iframe'leri doğrudan kontrol et
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
var directUrls = [];
|
||||
for (var k = 0; k < iframes.length; k++) {
|
||||
var src = iframes[k].src || iframes[k].getAttribute('data-src') || '';
|
||||
if (src && src.indexOf('vidrame') > -1 || src.indexOf('vidmoly') > -1 ||
|
||||
src.indexOf('closeload') > -1 || src.indexOf('rapidezvous') > -1 ||
|
||||
src.indexOf('player') > -1) {
|
||||
directUrls.push({ title: 'iframe-' + k, url: src });
|
||||
}
|
||||
}
|
||||
if (directUrls.length > 0) {
|
||||
window.flutter_inappwebview.callHandler('iframeUrlsFound', JSON.stringify(directUrls));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Fallback: data-url veya benzeri attribute'lerden
|
||||
var playerElements = document.querySelectorAll('[data-url], [data-src], [data-iframe]');
|
||||
var attrUrls = [];
|
||||
for (var m = 0; m < playerElements.length; m++) {
|
||||
var u = playerElements[m].getAttribute('data-url') ||
|
||||
playerElements[m].getAttribute('data-src') ||
|
||||
playerElements[m].getAttribute('data-iframe') || '';
|
||||
if (u && u.startsWith('http')) {
|
||||
attrUrls.push({ title: 'data-' + m, url: u });
|
||||
}
|
||||
}
|
||||
if (attrUrls.length > 0) {
|
||||
window.flutter_inappwebview.callHandler('iframeUrlsFound', JSON.stringify(attrUrls));
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
console.log('PHASE1: Top-level error: ' + e);
|
||||
}
|
||||
};
|
||||
})();
|
||||
''',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
forMainFrameOnly: true,
|
||||
);
|
||||
|
||||
webView = HeadlessInAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(pageUrl)),
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([extractScript]),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: true,
|
||||
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
|
||||
supportMultipleWindows: false,
|
||||
javaScriptCanOpenWindowsAutomatically: false,
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'iframeUrlsFound',
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty && !completer.isCompleted) {
|
||||
try {
|
||||
final List<dynamic> urls = jsonDecode(args[0].toString());
|
||||
if (urls.isNotEmpty) {
|
||||
final firstUrl = urls[0]['url']?.toString();
|
||||
debugPrint("EXTRACTION [PHASE 1]: Found ${urls.length} iframe(s), first: $firstUrl");
|
||||
cleanup();
|
||||
completer.complete(firstUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("EXTRACTION [PHASE 1]: JSON parse error: $e");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onReceivedServerTrustAuthRequest: (controller, challenge) async {
|
||||
return ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED);
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
debugPrint("EXTRACTION [PHASE 1]: Page loaded: $url");
|
||||
|
||||
// Sayfa yüklendikten sonra extraction fonksiyonunu çalıştır
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (completer.isCompleted) break;
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
if (completer.isCompleted) break;
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: "if(window.__extractIframeUrls) window.__extractIframeUrls();",
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("EXTRACTION [PHASE 1]: JS execution error: $e");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await webView?.run();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Aşama 2: vidrame.pro iframe sayfasından video URL'yi decode eder.
|
||||
/// Sayfa JWPlayer kullanır ve video kaynağı EE.dd() fonksiyonu ile şifrelenir.
|
||||
/// Strateji: Sayfanın kendi JS'ini çalıştırarak decode edilmiş URL'yi alıyoruz.
|
||||
Future<String?> _extractVideoFromIframe(String iframeUrl) async {
|
||||
final completer = Completer<String?>();
|
||||
HeadlessInAppWebView? webView;
|
||||
Timer? timeout;
|
||||
|
||||
void cleanup() {
|
||||
timeout?.cancel();
|
||||
webView?.dispose();
|
||||
webView = null;
|
||||
}
|
||||
|
||||
void handleFoundUrl(String url) {
|
||||
if (!completer.isCompleted) {
|
||||
cleanup();
|
||||
completer.complete(url);
|
||||
}
|
||||
}
|
||||
|
||||
timeout = Timer(const Duration(seconds: 15), () {
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint("EXTRACTION [PHASE 2]: Timeout");
|
||||
cleanup();
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
// JS: vidrame.pro sayfasından video URL'yi çıkar
|
||||
// EE.dd() fonksiyonu sayfada zaten tanımlı, biz sadece configs.sources[0].file değerini alıyoruz
|
||||
final decodeScript = UserScript(
|
||||
source: '''
|
||||
(function() {
|
||||
if (window.__phase2Injected) return;
|
||||
window.__phase2Injected = true;
|
||||
|
||||
console.log("PHASE2: Injected into: " + window.location.href);
|
||||
|
||||
// Yöntem 1: Network request'leri yakala (en güvenilir)
|
||||
function reportUrl(url) {
|
||||
if (url && (url.includes('.m3u8') || url.includes('.mp4'))) {
|
||||
console.log("PHASE2: Found video URL via intercept: " + url);
|
||||
window.flutter_inappwebview.callHandler('videoUrlFound', url);
|
||||
}
|
||||
}
|
||||
|
||||
var oldOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
reportUrl(url);
|
||||
return oldOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
var oldFetch = window.fetch;
|
||||
window.fetch = function() {
|
||||
var url = (arguments[0] instanceof Request) ? arguments[0].url : arguments[0];
|
||||
if (url) reportUrl(url.toString());
|
||||
return oldFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Yöntem 2: JWPlayer configs'ten doğrudan çıkar
|
||||
window.__extractFromJwPlayer = function() {
|
||||
try {
|
||||
// jwplayer instance'ını kontrol et
|
||||
if (typeof jwplayer !== 'undefined') {
|
||||
var pl = jwplayer();
|
||||
if (pl && pl.getPlaylistItem) {
|
||||
var item = pl.getPlaylistItem();
|
||||
if (item && item.file) {
|
||||
reportUrl(item.file);
|
||||
return true;
|
||||
}
|
||||
if (item && item.sources) {
|
||||
for (var i = 0; i < item.sources.length; i++) {
|
||||
if (item.sources[i].file) {
|
||||
reportUrl(item.sources[i].file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.log("PHASE2: JWPlayer check error: " + e);
|
||||
}
|
||||
|
||||
try {
|
||||
// configs variable'ını doğrudan kontrol et
|
||||
if (typeof configs !== 'undefined' && configs.sources) {
|
||||
for (var j = 0; j < configs.sources.length; j++) {
|
||||
if (configs.sources[j].file) {
|
||||
reportUrl(configs.sources[j].file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.log("PHASE2: configs check error: " + e);
|
||||
}
|
||||
|
||||
try {
|
||||
// EE.dd decode fonksiyonunu doğrudan dene
|
||||
if (typeof EE !== 'undefined' && EE.dd) {
|
||||
// Sayfadaki tüm scriptleri tara ve EE.dd(...) çağrılarını bul
|
||||
var scripts = document.querySelectorAll('script');
|
||||
for (var k = 0; k < scripts.length; k++) {
|
||||
var text = scripts[k].textContent || '';
|
||||
var matches = text.match(/EE\\.dd\\(["']([^"']+)["']\\)/g);
|
||||
if (matches) {
|
||||
for (var m = 0; m < matches.length; m++) {
|
||||
var encoded = matches[m].match(/EE\\.dd\\(["']([^"']+)["']\\)/);
|
||||
if (encoded && encoded[1]) {
|
||||
try {
|
||||
var decoded = EE.dd(encoded[1]);
|
||||
if (decoded) reportUrl(decoded);
|
||||
} catch(e2) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.log("PHASE2: EE.dd scan error: " + e);
|
||||
}
|
||||
|
||||
try {
|
||||
// HTML içinde doğrudan m3u8/mp4 regex tara
|
||||
var html = document.documentElement.innerHTML;
|
||||
var match = html.match(/https?:\\/\\/[^"'\\s<>]+\\.(m3u8|mp4)[^"'\\s<>]*/i);
|
||||
if (match) {
|
||||
reportUrl(match[0]);
|
||||
return true;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
try {
|
||||
// Video elementlerini kontrol et
|
||||
document.querySelectorAll('video').forEach(function(v) {
|
||||
if (v.src && v.src.length > 5) reportUrl(v.src);
|
||||
v.querySelectorAll('source').forEach(function(s) {
|
||||
if (s.src) reportUrl(s.src);
|
||||
});
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
return false;
|
||||
};
|
||||
})();
|
||||
''',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
forMainFrameOnly: false,
|
||||
);
|
||||
|
||||
webView = HeadlessInAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(iframeUrl)),
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([decodeScript]),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
|
||||
supportMultipleWindows: false,
|
||||
javaScriptCanOpenWindowsAutomatically: false,
|
||||
useShouldInterceptRequest: true,
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'videoUrlFound',
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty) {
|
||||
handleFoundUrl(args[0].toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onReceivedServerTrustAuthRequest: (controller, challenge) async {
|
||||
return ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED);
|
||||
},
|
||||
shouldInterceptRequest: (controller, request) async {
|
||||
final url = request.url.toString();
|
||||
if (_isVideoUrl(url)) {
|
||||
handleFoundUrl(url);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
debugPrint("EXTRACTION [PHASE 2]: iframe loaded: $url");
|
||||
|
||||
// JWPlayer'ın yüklenmesini bekle ve video URL'yi çıkar
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (completer.isCompleted) break;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (completer.isCompleted) break;
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: "if(window.__extractFromJwPlayer) window.__extractFromJwPlayer();",
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("EXTRACTION [PHASE 2]: JS execution error: $e");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await webView?.run();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dart tarafında EE.dd() decode (fallback olarak)
|
||||
// ============================================================
|
||||
|
||||
/// vidrame.pro'nun EE.dd() şifre çözme algoritması:
|
||||
/// 1. URL-safe base64 → standart base64 dönüştür
|
||||
/// 2. Base64 decode
|
||||
/// 3. ROT13 uygula
|
||||
/// 4. String reverse
|
||||
static String? decodEEdd(String encoded) {
|
||||
try {
|
||||
// URL-safe base64 → standart base64
|
||||
String s = encoded.replaceAll('-', '+').replaceAll('_', '/');
|
||||
while (s.length % 4 != 0) {
|
||||
s += '=';
|
||||
}
|
||||
|
||||
// Base64 decode
|
||||
final decoded = utf8.decode(base64Decode(s));
|
||||
|
||||
// ROT13
|
||||
final rot13 = _rot13(decoded);
|
||||
|
||||
// String reverse
|
||||
return rot13.split('').reversed.join('');
|
||||
} catch (e) {
|
||||
debugPrint("decodEEdd error: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// ROT13 dönüşümü (harfler ve rakamlar dahil, vidrame'nin özel versiyonu)
|
||||
static String _rot13(String input) {
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < input.length; i++) {
|
||||
final c = input.codeUnitAt(i);
|
||||
|
||||
if (c >= 65 && c <= 90) {
|
||||
// A-Z
|
||||
buffer.writeCharCode(((c - 65 + 13) % 26) + 65);
|
||||
} else if (c >= 97 && c <= 122) {
|
||||
// a-z
|
||||
buffer.writeCharCode(((c - 97 + 13) % 26) + 97);
|
||||
} else if (c >= 48 && c <= 57) {
|
||||
// 0-9
|
||||
buffer.writeCharCode(((c - 48 + 13) % 10) + 48);
|
||||
} else {
|
||||
buffer.writeCharCode(c);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Genel Amaçlı Sniffer (Fallback)
|
||||
// ============================================================
|
||||
|
||||
Future<String?> _extractGeneric(String pageUrl) async {
|
||||
final completer = Completer<String?>();
|
||||
Timer? timeoutTimer;
|
||||
|
||||
// HeadlessWebView'i yerel değişkene alıyoruz ki paralel çalışmalarda çakışmasın
|
||||
HeadlessInAppWebView? headlessWebView;
|
||||
|
||||
// Temizlik ve Bellek Yönetimi
|
||||
void cleanup() {
|
||||
timeoutTimer?.cancel();
|
||||
headlessWebView?.dispose();
|
||||
headlessWebView = null;
|
||||
}
|
||||
|
||||
// Bulunan URL'yi işleme
|
||||
void handleFoundUrl(String url) {
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint("EXTRACTION: SUCCESS! Found: $url");
|
||||
@@ -45,7 +525,7 @@ class LinkExtractionService implements ILinkExtractionService {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. UserScript: JS Kodunu Iframe'ler dahil tüm sayfalara DOM yüklenmeden ENJEKTE EDİYORUZ
|
||||
// UserScript: JS Kodunu Iframe'ler dahil tüm sayfalara DOM yüklenmeden ENJEKTE EDİYORUZ
|
||||
final snifferScript = UserScript(
|
||||
source: '''
|
||||
(function() {
|
||||
@@ -118,7 +598,7 @@ class LinkExtractionService implements ILinkExtractionService {
|
||||
|
||||
headlessWebView = HeadlessInAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(pageUrl)),
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([snifferScript]), // Scripti buradan veriyoruz
|
||||
initialUserScripts: UnmodifiableListView<UserScript>([snifferScript]),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
isInspectable: true,
|
||||
@@ -170,6 +650,10 @@ class LinkExtractionService implements ILinkExtractionService {
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Yardımcı Fonksiyonlar
|
||||
// ============================================================
|
||||
|
||||
bool _isVideoUrl(String url) {
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.startsWith('data:') || lower.length < 10) return false;
|
||||
|
||||
Reference in New Issue
Block a user