forked from eatvio/eatvio_app
feat(personalized_feed): added vk player state handling, separeted player logic
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||||
import 'package:dismissible_page/dismissible_page.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/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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/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:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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({
|
const _BlogPostMediaContent({
|
||||||
required this.galleryUrls,
|
required this.galleryUrls,
|
||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
@@ -237,61 +239,110 @@ class _BlogPostMediaContent extends StatelessWidget {
|
|||||||
final void Function(List<String> urls, int index) onOpenGallery;
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
final ValueChanged<int> onGalleryChanged;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (galleryUrls.length > 1) ...[
|
if (widget.galleryUrls.length > 1) ...[
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
CarouselSlider.builder(
|
NotificationListener<ScrollNotification>(
|
||||||
itemCount: galleryUrls.length + 1,
|
onNotification: _handleCarouselScroll,
|
||||||
itemBuilder: (context, itemIndex, _) {
|
child: CarouselSlider.builder(
|
||||||
if (itemIndex == galleryUrls.length) {
|
itemCount: widget.galleryUrls.length + 1,
|
||||||
if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb);
|
itemBuilder: (context, itemIndex, _) {
|
||||||
return const SizedBox.shrink();
|
if (itemIndex == widget.galleryUrls.length) {
|
||||||
}
|
if (widget.videoEmbedUrl != null) {
|
||||||
final itemUrl = galleryUrls[itemIndex];
|
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
||||||
return GestureDetector(
|
}
|
||||||
onTap: () => onOpenGallery(galleryUrls, itemIndex),
|
return const SizedBox.shrink();
|
||||||
child: CachedNetworkImage(
|
}
|
||||||
fit: BoxFit.cover,
|
final itemUrl = widget.galleryUrls[itemIndex];
|
||||||
width: MediaQuery.of(context).size.width,
|
return GestureDetector(
|
||||||
imageUrl: itemUrl,
|
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
child: CachedNetworkImage(
|
||||||
),
|
fit: BoxFit.cover,
|
||||||
);
|
width: MediaQuery.of(context).size.width,
|
||||||
},
|
imageUrl: itemUrl,
|
||||||
options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)),
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
options: CarouselOptions(
|
||||||
|
viewportFraction: 1,
|
||||||
|
height: 260,
|
||||||
|
enableInfiniteScroll: false,
|
||||||
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 12,
|
top: 12,
|
||||||
right: 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(
|
GestureDetector(
|
||||||
onTap: () => onOpenGallery(galleryUrls, 0),
|
onTap: () => widget.onOpenGallery(widget.galleryUrls, 0),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
height: 260,
|
height: 260,
|
||||||
imageUrl: galleryUrls.first,
|
imageUrl: widget.galleryUrls.first,
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else if (imageUrl.isNotEmpty) ...[
|
] else if (widget.imageUrl.isNotEmpty) ...[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => onOpenGallery([imageUrl], 0),
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: imageUrl,
|
tag: widget.imageUrl,
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
imageUrl: imageUrl,
|
imageUrl: widget.imageUrl,
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
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';
|
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user