feat(personalized_feed): added vk player state handling, separeted player logic

This commit is contained in:
misha
2026-07-21 15:15:28 +03:00
parent c7514e2cbf
commit 2dbd4a455f
6 changed files with 499 additions and 343 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
import 'package:dismissible_page/dismissible_page.dart';
@@ -13,7 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -218,7 +220,7 @@ class _BlogPostTextContent extends StatelessWidget {
}
}
class _BlogPostMediaContent extends StatelessWidget {
class _BlogPostMediaContent extends StatefulWidget {
const _BlogPostMediaContent({
required this.galleryUrls,
required this.imageUrl,
@@ -237,24 +239,61 @@ class _BlogPostMediaContent extends StatelessWidget {
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged;
@override
State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState();
}
class _BlogPostMediaContentState extends State<_BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 2);
bool _isCounterVisible = false;
Timer? _counterHideTimer;
@override
void dispose() {
_counterHideTimer?.cancel();
super.dispose();
}
bool _handleCarouselScroll(ScrollNotification notification) {
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
_counterHideTimer?.cancel();
if (!_isCounterVisible) {
setState(() => _isCounterVisible = true);
}
} else if (notification is ScrollEndNotification && _isCounterVisible) {
_counterHideTimer?.cancel();
_counterHideTimer = Timer(_counterDisplayDuration, () {
if (mounted) {
setState(() => _isCounterVisible = false);
}
});
}
return false;
}
@override
Widget build(BuildContext context) {
final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length;
final mediaContentCount = widget.videoEmbedUrl != null ? widget.galleryUrls.length + 1 : widget.galleryUrls.length;
return Column(
children: [
if (galleryUrls.length > 1) ...[
if (widget.galleryUrls.length > 1) ...[
Stack(
children: [
CarouselSlider.builder(
itemCount: galleryUrls.length + 1,
NotificationListener<ScrollNotification>(
onNotification: _handleCarouselScroll,
child: CarouselSlider.builder(
itemCount: widget.galleryUrls.length + 1,
itemBuilder: (context, itemIndex, _) {
if (itemIndex == galleryUrls.length) {
if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb);
if (itemIndex == widget.galleryUrls.length) {
if (widget.videoEmbedUrl != null) {
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
}
return const SizedBox.shrink();
}
final itemUrl = galleryUrls[itemIndex];
final itemUrl = widget.galleryUrls[itemIndex];
return GestureDetector(
onTap: () => onOpenGallery(galleryUrls, itemIndex),
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
@@ -263,35 +302,47 @@ class _BlogPostMediaContent extends StatelessWidget {
),
);
},
options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)),
options: CarouselOptions(
viewportFraction: 1,
height: 260,
enableInfiniteScroll: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index),
),
),
),
Positioned(
top: 12,
right: 12,
child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount),
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _isCounterVisible ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount),
),
),
),
],
),
] else if (galleryUrls.length == 1) ...[
] else if (widget.galleryUrls.length == 1) ...[
GestureDetector(
onTap: () => onOpenGallery(galleryUrls, 0),
onTap: () => widget.onOpenGallery(widget.galleryUrls, 0),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
height: 260,
imageUrl: galleryUrls.first,
imageUrl: widget.galleryUrls.first,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
] else if (imageUrl.isNotEmpty) ...[
] else if (widget.imageUrl.isNotEmpty) ...[
GestureDetector(
onTap: () => onOpenGallery([imageUrl], 0),
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: Hero(
tag: imageUrl,
tag: widget.imageUrl,
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: imageUrl,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),

View File

@@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
enum FeedVideoEmbedProvider { vk, other }
/// Сборка URL и HTML для embed-плеера ленты.
abstract final class FeedVideoEmbed {
static FeedVideoEmbedProvider detectProvider(Uri uri) {
final host = uri.host.toLowerCase();
final isVk = host == 'vk.com' ||
host.endsWith('.vk.com') ||
host == 'vk.ru' ||
host.endsWith('.vk.ru') ||
host == 'vkvideo.ru' ||
host.endsWith('.vkvideo.ru');
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
}
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
static Uri uriForPlayback(String url) {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['autoplay'] = '1';
params['mute'] = '1';
params.putIfAbsent('playsinline', () => '1');
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
params['js_api'] = '1';
}
return uri.replace(queryParameters: params);
}
static String buildHtml(String embedUrl) {
final uri = Uri.parse(embedUrl);
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
</style>
$headScripts
</head>
<body>
<iframe
id="player"
src="$src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
$bootstrap
</script>
</body>
</html>
''';
}
}

View File

@@ -0,0 +1,70 @@
/// JS-фрагменты для embed-плеера ленты.
///
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
abstract final class FeedVideoEmbedScripts {
static const readyChannel = 'EmbedReady';
/// Уведомляет Flutter, что embed готов к показу.
static String get notifyReady =>
'''
function notifyReady() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
} catch (e) {}
}
''';
/// Уведомляет Flutter об ошибке воспроизведения.
static String get notifyError =>
'''
function notifyError() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
} catch (e) {}
}
''';
/// Rutube: готовность через postMessage `player:canPlay`.
static String get rutubeCanPlayListener => '''
window.addEventListener('message', function (event) {
try {
var type = JSON.parse(event.data).type;
if (type === 'player:canPlay') {
notifyReady();
}
} catch (e) {}
});
''';
/// VK Video JS API: воспроизведение началось на событии `started`.
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
static String get vkPlayerReadyHandler => '''
try {
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
vkPlayer.on('started', function () {
notifyReady();
});
vkPlayer.on('error', function () {
notifyError();
});
}
} catch (e) {}
''';
/// Внешний скрипт VK VideoPlayer API.
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
static String bootstrap({required bool includeVk}) {
final vkPart = includeVk ? vkPlayerReadyHandler : '';
return '''
(function () {
$notifyReady
$notifyError
$rutubeCanPlayListener
$vkPart
})();
''';
}
}

View File

@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/VK/YouTube):
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
class FeedVideoPlayer extends StatefulWidget {
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
final String embedUrl;
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
final String? videoUrlThumb;
@override
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
}
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
static const Duration _readyFallbackDelay = Duration(seconds: 4);
/// Пока null — WebView не рендерится. Заполняется по жесту play.
InAppWebViewInitialData? _initialData;
bool _isLoading = false;
bool _isReady = false;
bool _hasError = false;
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
Timer? _readyFallback;
@override
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
_reset();
}
}
@override
void dispose() {
_readyFallback?.cancel();
super.dispose();
}
void _reset() {
_readyFallback?.cancel();
_readyFallback = null;
_initialData = null;
_isLoading = false;
_isReady = false;
_hasError = false;
}
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;
setState(() {
_isLoading = true;
_hasError = false;
});
try {
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
if (!mounted) return;
setState(() {
_initialData = InAppWebViewInitialData(
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
mimeType: 'text/html',
encoding: 'utf8',
baseUrl: WebUri(baseUrl),
);
});
} catch (e, st) {
if (kDebugMode) {
debugPrint('FeedVideoPlayer load failed: $e\n$st');
}
_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: FeedVideoEmbedScripts.readyChannel,
callback: (args) {
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
if (status == 'error') {
_markError();
} else {
_markReady();
}
},
);
}
void _onLoadStop(InAppWebViewController _, WebUri? _) {
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
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(
height: 220,
width: MediaQuery.sizeOf(context).width,
child: Stack(
fit: StackFit.expand,
children: [
// WebView создаётся только после play и до ready остаётся скрытым под превью.
if (_initialData != null)
Opacity(
opacity: _isReady ? 1 : 0,
child: IgnorePointer(
ignoring: !_isReady,
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)
FeedVideoPreviewPlaceholder(
previewUrl: widget.videoUrlThumb,
isLoading: _isLoading,
hasError: _hasError,
onPlay: _onPlayPressed,
onRetry: _onPlayPressed,
),
],
),
);
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
if (_isReady) return player;
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class FeedVideoPreviewPlaceholder extends StatelessWidget {
const FeedVideoPreviewPlaceholder({
super.key,
required this.previewUrl,
required this.isLoading,
required this.hasError,
required this.onPlay,
required this.onRetry,
});
final String? previewUrl;
final bool isLoading;
final bool hasError;
final VoidCallback onPlay;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (previewUrl != null && previewUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: previewUrl!,
fit: BoxFit.cover,
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
)
else
const ColoredBox(color: Color(0xFF2C2C2C)),
if (hasError)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
const SizedBox(height: 8),
TextButton(
onPressed: onRetry,
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
),
],
),
)
else if (isLoading)
const Center(child: CircularProgressIndicator(color: Colors.white))
else
Center(
child: GestureDetector(
onTap: onPlay,
child: Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
padding: const EdgeInsets.all(12),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
),
),
),
],
);
}
}

View File

@@ -1,312 +1 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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});
final String embedUrl;
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
final String? videoUrlThumb;
@override
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
}
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
static const _readyChannel = 'EmbedReady';
static const Duration _readyFallbackDelay = Duration(seconds: 4);
/// Пока 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);
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
_reset();
}
}
@override
void dispose() {
_readyFallback?.cancel();
super.dispose();
}
void _reset() {
_readyFallback?.cancel();
_readyFallback = null;
_initialData = null;
_isLoading = false;
_isReady = false;
_hasError = false;
}
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
Uri _embedUriForPlayback(String url) {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['autoplay'] = '1';
params['mute'] = '1';
params.putIfAbsent('playsinline', () => '1');
return uri.replace(queryParameters: params);
}
String _embedHtml(String embedUrl) {
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<iframe
id="player"
src="$src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
(function () {
function notify() {
try { window.flutter_inappwebview.callHandler('$_readyChannel', 'ready'); } catch (e) {}
}
window.addEventListener('message', function(event) {
try {
const type = JSON.parse(event.data).type;
console.log(type);
if (type === 'player:canPlay') {
notify();
}
} catch (e) {}
});
})();
</script>
</body>
</html>
''';
}
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;
setState(() {
_isLoading = true;
_hasError = false;
});
try {
final embedUri = _embedUriForPlayback(widget.embedUrl);
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
if (!mounted) return;
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');
}
_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(
height: 220,
width: MediaQuery.sizeOf(context).width,
child: Stack(
fit: StackFit.expand,
children: [
// WebView создаётся только после play и до ready остаётся скрытым под превью.
if (_initialData != null)
Opacity(
opacity: _isReady ? 1 : 0,
child: IgnorePointer(
ignoring: !_isReady,
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)
_VideoPreviewPlaceholder(
previewUrl: widget.videoUrlThumb,
isLoading: _isLoading,
hasError: _hasError,
onPlay: _onPlayPressed,
onRetry: _onPlayPressed,
),
],
),
);
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
if (_isReady) return player;
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
}
}
class _VideoPreviewPlaceholder extends StatelessWidget {
const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry});
final String? previewUrl;
final bool isLoading;
final bool hasError;
final VoidCallback onPlay;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (previewUrl != null && previewUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: previewUrl!,
fit: BoxFit.cover,
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
)
else
const ColoredBox(color: Color(0xFF2C2C2C)),
if (hasError)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
const SizedBox(height: 8),
TextButton(
onPressed: onRetry,
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
),
],
),
)
else if (isLoading)
const Center(child: CircularProgressIndicator(color: Colors.white))
else
Center(
child: GestureDetector(
onTap: onPlay,
child: Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
padding: const EdgeInsets.all(12),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
),
),
),
],
);
}
}
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';