forked from eatvio/eatvio_app
refactor(video,webview): webview lib replaced on feed
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
25
pubspec.yaml
25
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"
|
||||
|
||||
Reference in New Issue
Block a user