From 9c5c6df751d39919b242adadba569cf3a01d2a29 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Mon, 20 Jul 2026 18:15:25 +0300 Subject: [PATCH] refactor(video,webview): webview lib replaced on feed --- .kilocode/rules/memory-bank/context.md | 15 +- .../widgets/feed_video_player_widget.dart | 172 +++++++++++------- pubspec.yaml | 25 ++- 3 files changed, 130 insertions(+), 82 deletions(-) diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md index 0bde3ccf..ff45966e 100644 --- a/.kilocode/rules/memory-bank/context.md +++ b/.kilocode/rules/memory-bank/context.md @@ -1,7 +1,7 @@ # Current Context ## Project Status -Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production. +Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production. ## Recent Activity - Memory Bank initialization completed @@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1. - Architecture and technology stack catalogued ## Current Focus -Ready for development tasks. No active work in progress. +Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`) +мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс +fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует +Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview` +делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play → +скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с +для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen. +Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`. ## Known Issues -None documented at initialization. +- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`, + `post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие, + связаны с fixtures/маппингом, не с видеоплеером. ## Next Steps Awaiting user direction for: diff --git a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart index 701988a9..29e85300 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -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 { 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 { @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 { @@ -108,12 +119,25 @@ class _FeedVideoPlayerState extends State { 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 _onPlayPressed() async { if (_isLoading || _isReady || !mounted) return; @@ -126,62 +150,68 @@ class _FeedVideoPlayerState extends State { 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 {}); - } 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 { 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) diff --git a/pubspec.yaml b/pubspec.yaml index 8c44cc5a..aed9b3e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eatvio_app description: A new Flutter project. version: 1.3.24+106 -publish_to: 'none' +publish_to: "none" environment: sdk: ^3.10.4 @@ -82,12 +82,11 @@ dependencies: rxdart: ^0.27.7 gpassword: ^1.0.0 webview_flutter_platform_interface: ^2.14.0 + flutter_inappwebview: ^6.1.5 intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 sbp: 0.1.0 - webview_flutter_android: ^4.12.0 - webview_flutter_wkwebview: ^3.25.1 dev_dependencies: build_runner: ^2.10.4 @@ -167,14 +166,14 @@ flutter_intl: flutter_native_splash: android: true ios: true - color: '#FFFFFF' - image_android: 'assets/splash/splash.png' - image_dark_android: 'assets/splash/splash.png' - image_ios: 'assets/splash/splash.png' - image_dark_ios: 'assets/splash/splash.png' - branding_android: 'assets/splash/branding.png' - branding_dark_android: 'assets/splash/branding.png' + color: "#FFFFFF" + image_android: "assets/splash/splash.png" + image_dark_android: "assets/splash/splash.png" + image_ios: "assets/splash/splash.png" + image_dark_ios: "assets/splash/splash.png" + branding_android: "assets/splash/branding.png" + branding_dark_android: "assets/splash/branding.png" android_12: - image: 'assets/splash/splash.png' - image_dark: 'assets/splash/splash.png' - branding: 'assets/splash/branding.png' + image: "assets/splash/splash.png" + image_dark: "assets/splash/splash.png" + branding: "assets/splash/branding.png"