forked from eatvio/eatvio_app
Merge pull request 'feat(personalized_feed): added vk player state handling, separeted player logic' (#1001) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1001
This commit is contained in:
@@ -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,61 +239,110 @@ 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,
|
||||
itemBuilder: (context, itemIndex, _) {
|
||||
if (itemIndex == galleryUrls.length) {
|
||||
if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final itemUrl = galleryUrls[itemIndex];
|
||||
return GestureDetector(
|
||||
onTap: () => onOpenGallery(galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)),
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleCarouselScroll,
|
||||
child: CarouselSlider.builder(
|
||||
itemCount: widget.galleryUrls.length + 1,
|
||||
itemBuilder: (context, itemIndex, _) {
|
||||
if (itemIndex == widget.galleryUrls.length) {
|
||||
if (widget.videoEmbedUrl != null) {
|
||||
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final itemUrl = widget.galleryUrls[itemIndex];
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})();
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user