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

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

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,22 +92,23 @@ 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);
try {
const type = JSON.parse(event.data).type;
console.log(type);
if (type === 'player:canPlay') {
notify();
}
} catch (e) {}
});
})();
</script>
@@ -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,60 +150,66 @@ 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;
});
},
),
_initialData = InAppWebViewInitialData(
data: _embedHtml(embedUri.toString()),
mimeType: 'text/html',
encoding: 'utf8',
baseUrl: WebUri(baseUrl),
);
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);
});
} 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
@@ -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)

View File

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