From 2dbd4a455f51d407ac4adeb215744c6e9aad100c Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 21 Jul 2026 15:15:28 +0300 Subject: [PATCH] feat(personalized_feed): added vk player state handling, separeted player logic --- .../widgets/blog_post_widget.dart | 113 +++++-- .../feed_video_player/feed_video_embed.dart | 70 ++++ .../feed_video_embed_scripts.dart | 70 ++++ .../feed_video_player_widget.dart | 213 ++++++++++++ .../feed_video_preview_placeholder.dart | 63 ++++ .../widgets/feed_video_player_widget.dart | 313 +----------------- 6 files changed, 499 insertions(+), 343 deletions(-) create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 7ad825a5..8751798b 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -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 urls, int index) onOpenGallery; final ValueChanged 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( + 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(), ), ), diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart new file mode 100644 index 00000000..056d0e37 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart @@ -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.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 ? '' : ''; + final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk); + + return ''' + + + + + + + $headScripts + + + + + + +'''; + } +} diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart new file mode 100644 index 00000000..5d661bde --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart @@ -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 ` - - -'''; - } - - 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 _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';