forked from eatvio/eatvio_app
refactor(video,webview): webview lib replaced on feed
This commit is contained in:
@@ -5,15 +5,18 @@ import 'dart:developer';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Embed-видео в ленте.
|
||||
///
|
||||
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||
///
|
||||
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/YouTube):
|
||||
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||
class FeedVideoPlayer extends StatefulWidget {
|
||||
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||
|
||||
@@ -28,12 +31,17 @@ class FeedVideoPlayer extends StatefulWidget {
|
||||
|
||||
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
static const _readyChannel = 'EmbedReady';
|
||||
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||
|
||||
WebViewController? _controller;
|
||||
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||
InAppWebViewInitialData? _initialData;
|
||||
bool _isLoading = false;
|
||||
bool _isReady = false;
|
||||
bool _hasError = false;
|
||||
|
||||
/// Страховка готовности для провайдеров без Rutube-протокола `player:canPlay`.
|
||||
Timer? _readyFallback;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -44,12 +52,14 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller = null;
|
||||
_readyFallback?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_controller = null;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
_initialData = null;
|
||||
_isLoading = false;
|
||||
_isReady = false;
|
||||
_hasError = false;
|
||||
@@ -82,23 +92,24 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
<iframe
|
||||
id="player"
|
||||
src="$src"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture;"
|
||||
allowfullscreen=true
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
var iframe = document.getElementById('player');
|
||||
function notify() {
|
||||
try { $_readyChannel.postMessage('ready'); } catch (e) {}
|
||||
try { window.flutter_inappwebview.callHandler('$_readyChannel', 'ready'); } catch (e) {}
|
||||
}
|
||||
window.addEventListener('message', function(event) {
|
||||
console.log(JSON.parse(event.data).type);
|
||||
const type = JSON.parse(event.data).type;
|
||||
if (type === 'player:canPlay') {
|
||||
notify();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const type = JSON.parse(event.data).type;
|
||||
console.log(type);
|
||||
if (type === 'player:canPlay') {
|
||||
notify();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -108,12 +119,25 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
|
||||
void _markReady() {
|
||||
if (!mounted || _isReady || _hasError) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_isReady = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _markError() {
|
||||
if (!mounted || _isReady) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_initialData = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPlayPressed() async {
|
||||
if (_isLoading || _isReady || !mounted) return;
|
||||
|
||||
@@ -126,62 +150,68 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
final embedUri = _embedUriForPlayback(widget.embedUrl);
|
||||
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||
|
||||
late final PlatformWebViewControllerCreationParams params;
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
// Жест play уже был — разрешаем media без дополнительного тапа.
|
||||
params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{});
|
||||
} else {
|
||||
params = const PlatformWebViewControllerCreationParams();
|
||||
}
|
||||
|
||||
final controller = WebViewController.fromPlatformCreationParams(params)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady())
|
||||
..setOnConsoleMessage((message) {
|
||||
log('FeedVideoPlayer console message: ${message.message}');
|
||||
})
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted) return;
|
||||
if (error.isForMainFrame != true) return;
|
||||
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||
if (_isReady) return;
|
||||
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_controller = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final platform = controller.platform;
|
||||
if (platform is AndroidWebViewController) {
|
||||
// Жест уже был на кнопке play.
|
||||
await platform.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
|
||||
await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _controller = controller);
|
||||
setState(() {
|
||||
_initialData = InAppWebViewInitialData(
|
||||
data: _embedHtml(embedUri.toString()),
|
||||
mimeType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
baseUrl: WebUri(baseUrl),
|
||||
);
|
||||
});
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_controller = null;
|
||||
});
|
||||
}
|
||||
_markError();
|
||||
}
|
||||
}
|
||||
|
||||
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||
iframeAllowFullscreen: true,
|
||||
transparentBackground: false,
|
||||
);
|
||||
|
||||
void _onWebViewCreated(InAppWebViewController controller) {
|
||||
controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady());
|
||||
}
|
||||
|
||||
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||
// Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube),
|
||||
// покажем WebView по таймауту после полной загрузки.
|
||||
if (_isReady || _hasError || !mounted) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||
if (mounted && !_isReady && !_hasError) _markReady();
|
||||
});
|
||||
}
|
||||
|
||||
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||
if (!mounted) return;
|
||||
if (request.isForMainFrame != true) return;
|
||||
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||
if (_isReady) return;
|
||||
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||
_markError();
|
||||
}
|
||||
|
||||
void _onEnterFullscreen(InAppWebViewController _) {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
}
|
||||
|
||||
void _onExitFullscreen(InAppWebViewController _) {
|
||||
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = SizedBox(
|
||||
@@ -191,12 +221,22 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||
if (_controller != null)
|
||||
if (_initialData != null)
|
||||
Opacity(
|
||||
opacity: _isReady ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isReady,
|
||||
child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!),
|
||||
child: InAppWebView(
|
||||
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||
initialData: _initialData,
|
||||
initialSettings: _settings,
|
||||
onWebViewCreated: _onWebViewCreated,
|
||||
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||
onLoadStop: _onLoadStop,
|
||||
onReceivedError: _onReceivedError,
|
||||
onEnterFullscreen: _onEnterFullscreen,
|
||||
onExitFullscreen: _onExitFullscreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isReady)
|
||||
|
||||
Reference in New Issue
Block a user