diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 605b0c4..b215bb1 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const prismaChangelog = [ + PrismaChange( + version: '3.6.4', + title: 'Полное исправление воспроизведения видео на ПК (устранение синего экрана)', + bullets: [ + 'Программный рендеринг (S/W) по умолчанию для ПК: на десктопных платформах отключен проблемный OpenGL FBO мост аппаратного ускорения, вызывавший синий экран из-за отсутствия или конфликтов драйверов VAAPI/VDPAU в Linux/AppImage.', + 'Переключатель декодера H/W / S/W в плеере: на верхней панели управления видеоплеером добавлена кнопка быстрого переключения между аппаратным (H/W) и программным (S/W) декодированием на лету.', + 'Отображение постера-превью: под видеоплеером теперь отображается превью-постер на время буферизации, исключая появление пустых областей.', + 'Безопасное превью в ленте: предпросмотр видео в карточках постов на ПК также переведен на надежный программный рендерер.', + ], + ), PrismaChange( version: '3.6.3', title: 'Горизонтальный скролл колесиком мыши на ПК и исправление синего экрана в видеоплеере', diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index f337791..2fc99cc 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -78,6 +78,8 @@ class _PostMediaViewerState extends State String? _videoError; bool _retriedFormatError = false; bool _softwareDecodingFallback = false; + late bool _useSoftwareDecoding = + Platform.isLinux || Platform.isWindows || Platform.isMacOS; Timer? _hideTimer; StreamSubscription? _errorSubscription; StreamSubscription? _positionSubscription; @@ -109,6 +111,8 @@ class _PostMediaViewerState extends State _videoError = null; _retriedFormatError = false; _softwareDecodingFallback = false; + _useSoftwareDecoding = + Platform.isLinux || Platform.isWindows || Platform.isMacOS; _imageUrls = _buildImageUrls(widget.post); _videoUrls = _buildVideoUrls(widget.post); if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) { @@ -226,6 +230,11 @@ class _PostMediaViewerState extends State aspectRatio: widget.post.width > 0 && widget.post.height > 0 ? widget.post.width / widget.post.height : 16 / 9, + previewUrl: widget.post.previewUrl.isNotEmpty + ? widget.post.previewUrl + : widget.post.sampleUrl, + isSoftwareDecoding: _useSoftwareDecoding, + onToggleDecoder: _toggleDecoderMode, controlsVisible: _controlsVisible, coverVideo: _coverVideo, muted: _muted, @@ -444,13 +453,12 @@ class _PostMediaViewerState extends State void _initializeVideo() { MediaKit.ensureInitialized(); _player = Player(); + final bool enableHw = !_useSoftwareDecoding && !_softwareDecodingFallback; _controller = VideoController( _player!, configuration: VideoControllerConfiguration( - enableHardwareAcceleration: !_softwareDecodingFallback, - hwdec: _softwareDecodingFallback - ? 'no' - : (Platform.isAndroid ? 'auto-safe' : 'auto'), + enableHardwareAcceleration: enableHw, + hwdec: enableHw ? (Platform.isAndroid ? 'auto-safe' : 'auto') : 'no', ), ); _applyVolume(); @@ -525,6 +533,7 @@ class _PostMediaViewerState extends State lower.contains('failed to recognize file format'))) { _retriedFormatError = true; _softwareDecodingFallback = true; + _useSoftwareDecoding = true; _disposeVideo(); await Future.delayed(const Duration(milliseconds: 250)); if (mounted) { @@ -542,6 +551,7 @@ class _PostMediaViewerState extends State _videoIndex = 0; _retriedFormatError = false; _softwareDecodingFallback = true; + _useSoftwareDecoding = true; _disposeVideo(); await Future.delayed(const Duration(milliseconds: 150)); if (mounted) { @@ -549,6 +559,26 @@ class _PostMediaViewerState extends State } } + void _toggleDecoderMode() { + setState(() { + _useSoftwareDecoding = !_useSoftwareDecoding; + _softwareDecodingFallback = false; + _retriedFormatError = false; + }); + _disposeVideo(); + _initializeVideo(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text( + _useSoftwareDecoding + ? 'Включено программное декодирование (S/W)' + : 'Включено аппаратное декодирование (H/W)', + ), + duration: const Duration(seconds: 2), + ), + ); + } + void _disposeVideo() { _hideTimer?.cancel(); _errorSubscription?.cancel(); @@ -690,6 +720,11 @@ class _PostMediaViewerState extends State halfVolume: _halfVolume, coverVideo: _coverVideo, initialVolume: _currentVolume, + previewUrl: widget.post.previewUrl.isNotEmpty + ? widget.post.previewUrl + : widget.post.sampleUrl, + isSoftwareDecoding: _useSoftwareDecoding, + onToggleDecoder: _toggleDecoderMode, errorMessage: _videoError, onRetry: _retryVideo, onChanged: (snapshot) { @@ -886,6 +921,9 @@ class _FullscreenVideoPage extends StatefulWidget { required this.halfVolume, required this.coverVideo, this.initialVolume = 100.0, + this.previewUrl, + required this.isSoftwareDecoding, + required this.onToggleDecoder, required this.errorMessage, required this.onRetry, required this.onChanged, @@ -900,6 +938,9 @@ class _FullscreenVideoPage extends StatefulWidget { final bool halfVolume; final bool coverVideo; final double initialVolume; + final String? previewUrl; + final bool isSoftwareDecoding; + final VoidCallback onToggleDecoder; final String? errorMessage; final VoidCallback onRetry; final ValueChanged onChanged; @@ -1024,6 +1065,9 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { player: widget.player, controller: widget.controller, aspectRatio: widget.aspectRatio, + previewUrl: widget.previewUrl, + isSoftwareDecoding: widget.isSoftwareDecoding, + onToggleDecoder: widget.onToggleDecoder, controlsVisible: _controlsVisible, coverVideo: _coverVideo, muted: _muted, @@ -1575,6 +1619,9 @@ class _VideoSurface extends StatefulWidget { required this.player, required this.controller, required this.aspectRatio, + this.previewUrl, + required this.isSoftwareDecoding, + required this.onToggleDecoder, required this.controlsVisible, required this.coverVideo, required this.muted, @@ -1599,6 +1646,9 @@ class _VideoSurface extends StatefulWidget { final Player player; final VideoController controller; final double aspectRatio; + final String? previewUrl; + final bool isSoftwareDecoding; + final VoidCallback onToggleDecoder; final bool controlsVisible; final bool coverVideo; final bool muted; @@ -1757,15 +1807,22 @@ class _VideoSurfaceState extends State<_VideoSurface> { return Stack( fit: StackFit.expand, children: [ - ColoredBox( - color: Colors.black, - child: Video( - controller: widget.controller, - fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain, - controls: null, - pauseUponEnteringBackgroundMode: false, - resumeUponEnteringForegroundMode: false, + if (widget.previewUrl != null && widget.previewUrl!.isNotEmpty) + Positioned.fill( + child: CachedNetworkImage( + imageUrl: widget.previewUrl!, + fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain, + memCacheWidth: 1920, + memCacheHeight: 1080, + ), ), + Video( + controller: widget.controller, + fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain, + fill: Colors.transparent, + controls: null, + pauseUponEnteringBackgroundMode: false, + resumeUponEnteringForegroundMode: false, ), if (_currentBrightness < 0.99) Positioned.fill( @@ -1844,6 +1901,8 @@ class _VideoSurfaceState extends State<_VideoSurface> { fullscreen: widget.fullscreen, isLocked: _isLocked, isLandscape: widget.isLandscape, + isSoftwareDecoding: widget.isSoftwareDecoding, + onToggleDecoder: widget.onToggleDecoder, onToggleOrientation: widget.onToggleOrientation, onToggleLock: () => setState(() => _isLocked = !_isLocked), onToggleFit: widget.onToggleFit, @@ -2655,6 +2714,8 @@ class _VideoControls extends StatefulWidget { required this.fullscreen, required this.isLocked, this.isLandscape, + required this.isSoftwareDecoding, + required this.onToggleDecoder, this.onToggleOrientation, required this.onToggleLock, required this.onToggleFit, @@ -2674,6 +2735,8 @@ class _VideoControls extends StatefulWidget { final bool fullscreen; final bool isLocked; final bool? isLandscape; + final bool isSoftwareDecoding; + final VoidCallback onToggleDecoder; final VoidCallback? onToggleOrientation; final VoidCallback onToggleLock; final VoidCallback onToggleFit; @@ -2773,6 +2836,17 @@ class _VideoControlsState extends State<_VideoControls> { onPressed: widget.onToggleLock, ), const Spacer(), + _RoundControlButton( + tooltip: widget.isSoftwareDecoding + ? 'Декодер: S/W (программный). Нажмите для переключения на H/W' + : 'Декодер: H/W (аппаратный). Нажмите для переключения на S/W', + selected: !widget.isSoftwareDecoding, + icon: widget.isSoftwareDecoding + ? Icons.memory_rounded + : Icons.speed_rounded, + onPressed: widget.onToggleDecoder, + ), + const SizedBox(width: 8), _SpeedBadgeButton( currentRate: currentRate, onSelected: (newRate) => widget.player.setRate(newRate), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 1567d35..a7353ac 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -580,7 +580,7 @@ class _DesktopShellState extends State<_DesktopShell> ), ), child: Text( - 'v3.6.3', + 'v3.6.4', style: TextStyle( fontSize: 10, fontWeight: diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 655c368..bb277ef 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -590,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> { super.initState(); MediaKit.ensureInitialized(); _player = Player(); - _controller = VideoController(_player); + final isDesktop = + Platform.isLinux || Platform.isWindows || Platform.isMacOS; + _controller = VideoController( + _player, + configuration: VideoControllerConfiguration( + enableHardwareAcceleration: !isDesktop, + hwdec: isDesktop ? 'no' : 'auto-safe', + ), + ); _player.stream.error.listen((_) { if (mounted) setState(() => _failed = true); }); @@ -633,15 +642,13 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> { AnimatedOpacity( opacity: _ready ? 1 : 0, duration: AppMotion.duration(context, 180), - child: ColoredBox( - color: Colors.black, - child: Video( - controller: _controller, - fit: BoxFit.cover, - controls: null, - pauseUponEnteringBackgroundMode: false, - resumeUponEnteringForegroundMode: false, - ), + child: Video( + controller: _controller, + fit: BoxFit.cover, + fill: Colors.transparent, + controls: null, + pauseUponEnteringBackgroundMode: false, + resumeUponEnteringForegroundMode: false, ), ), ], diff --git a/pubspec.yaml b/pubspec.yaml index f1d9676..58bcf24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.6.3+85 +version: 3.6.4+86 environment: sdk: ">=3.3.0 <4.0.0"