refactor(video,webview): webview lib replaced on feed

This commit is contained in:
2026-07-20 18:15:25 +03:00
parent 48a984f5e9
commit 9c5c6df751
3 changed files with 130 additions and 82 deletions

View File

@@ -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)