From 1fe0969f18c340be3eda78241b6435ef34053455 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 23 May 2026 12:42:46 +0300 Subject: [PATCH] Improve cross-platform video player --- .../widgets/post_media_viewer.dart | 513 +++++++++++++++--- 1 file changed, 429 insertions(+), 84 deletions(-) diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 5667689..4e2521c 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -2,15 +2,25 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import '../../../../backend/backend.dart'; class PostMediaViewer extends StatefulWidget { - const PostMediaViewer({required this.post, super.key}); + const PostMediaViewer({ + required this.post, + this.fullscreen = false, + this.initialPosition = Duration.zero, + this.autoplay = false, + super.key, + }); final Post post; + final bool fullscreen; + final Duration initialPosition; + final bool autoplay; @override State createState() => _PostMediaViewerState(); @@ -20,29 +30,45 @@ class _PostMediaViewerState extends State { Player? _player; VideoController? _controller; late List _imageUrls; + late List _videoUrls; int _imageIndex = 0; + int _videoIndex = 0; bool _controlsVisible = true; bool _coverVideo = false; bool _muted = false; + String? _videoError; Timer? _hideTimer; + StreamSubscription? _errorSubscription; @override void initState() { super.initState(); _imageUrls = _buildImageUrls(widget.post); - if (widget.post.fileType == 'video') { - MediaKit.ensureInitialized(); - _player = Player(); - _controller = VideoController(_player!); - _player!.open(Media(widget.post.fileUrl), play: false); - _scheduleControlsHide(); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } + } + + @override + void didUpdateWidget(covariant PostMediaViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.cacheKey != widget.post.cacheKey) { + _disposeVideo(); + _imageIndex = 0; + _videoIndex = 0; + _videoError = null; + _imageUrls = _buildImageUrls(widget.post); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } } } @override void dispose() { - _hideTimer?.cancel(); - _player?.dispose(); + _disposeVideo(); super.dispose(); } @@ -58,7 +84,10 @@ class _PostMediaViewerState extends State { controlsVisible: _controlsVisible, coverVideo: _coverVideo, muted: _muted, + fullscreen: widget.fullscreen, + errorMessage: _videoError, onInteract: _showControls, + onRetry: _retryVideo, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); _showControls(); @@ -69,8 +98,12 @@ class _PostMediaViewerState extends State { await _player!.setVolume(nextMuted ? 0 : 100); _showControls(); }, + onFullscreen: widget.fullscreen + ? () => Navigator.of(context).maybePop() + : () => _openFullscreen(context), ); } + final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; return InteractiveViewer( child: CachedNetworkImage( @@ -102,6 +135,56 @@ class _PostMediaViewerState extends State { ); } + void _initializeVideo() { + MediaKit.ensureInitialized(); + _player = Player(); + _controller = VideoController(_player!); + _errorSubscription = _player!.stream.error.listen((message) { + if (!mounted) return; + if (_videoIndex < _videoUrls.length - 1) { + _videoIndex++; + _openVideo(play: false); + return; + } + setState(() => _videoError = message); + _showControls(); + }); + _openVideo(play: widget.autoplay); + _scheduleControlsHide(); + } + + Future _openVideo({required bool play}) async { + final player = _player; + if (player == null || _videoUrls.isEmpty) return; + setState(() => _videoError = null); + await player.open( + Media( + _videoUrls[_videoIndex], + httpHeaders: _headersFor(widget.post), + ), + play: play, + ); + if (widget.initialPosition > Duration.zero) { + await player.seek(widget.initialPosition); + } + } + + Future _retryVideo() async { + _videoIndex = 0; + await _openVideo(play: true); + _showControls(); + } + + void _disposeVideo() { + _hideTimer?.cancel(); + _hideTimer = null; + _errorSubscription?.cancel(); + _errorSubscription = null; + _player?.dispose(); + _player = null; + _controller = null; + } + List _buildImageUrls(Post post) { return { post.sampleUrl, @@ -110,14 +193,43 @@ class _PostMediaViewerState extends State { }.where((url) => url.trim().isNotEmpty).toList(); } + List _buildVideoUrls(Post post) { + return { + post.fileUrl, + if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl, + if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl, + }.where((url) => url.trim().isNotEmpty).toList(); + } + + bool _isVideo(Post post) { + final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); + return value.contains('video') || + value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } + + bool _looksLikeVideoUrl(String url) { + final value = url.toLowerCase(); + return value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } + Map _headersFor(Post post) { return { - 'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser', + 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + 'Accept': '*/*', if (post.providerName.toLowerCase().contains('gelbooru') || post.fileUrl.contains('gelbooru.com') || post.sampleUrl.contains('gelbooru.com') || post.previewUrl.contains('gelbooru.com')) 'Referer': 'https://gelbooru.com/', + if (post.providerName.toLowerCase().contains('rule34') || + post.fileUrl.contains('rule34') || + post.sampleUrl.contains('rule34') || + post.previewUrl.contains('rule34')) + 'Referer': 'https://rule34.xxx/', }; } @@ -130,9 +242,89 @@ class _PostMediaViewerState extends State { void _scheduleControlsHide() { _hideTimer?.cancel(); _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted) setState(() => _controlsVisible = false); + if (mounted && _videoError == null) { + setState(() => _controlsVisible = false); + } }); } + + Future _openFullscreen(BuildContext context) async { + final player = _player; + final position = player?.state.position ?? Duration.zero; + final wasPlaying = player?.state.playing ?? false; + if (wasPlaying) await player?.pause(); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _FullscreenVideoPage( + post: widget.post, + startAt: position, + autoplay: wasPlaying, + ), + ), + ); + if (wasPlaying) await player?.play(); + } +} + +class _FullscreenVideoPage extends StatefulWidget { + const _FullscreenVideoPage({ + required this.post, + required this.startAt, + required this.autoplay, + }); + + final Post post; + final Duration startAt; + final bool autoplay; + + @override + State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); +} + +class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { + @override + void initState() { + super.initState(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Center( + child: PostMediaViewer( + post: widget.post, + fullscreen: true, + initialPosition: widget.startAt, + autoplay: widget.autoplay, + ), + ), + Positioned( + top: 8, + right: 8, + child: IconButton.filledTonal( + tooltip: 'Close fullscreen', + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.close_rounded), + ), + ), + ], + ), + ), + ); + } } class _VideoSurface extends StatelessWidget { @@ -143,9 +335,13 @@ class _VideoSurface extends StatelessWidget { required this.controlsVisible, required this.coverVideo, required this.muted, + required this.fullscreen, + required this.errorMessage, required this.onInteract, + required this.onRetry, required this.onToggleFit, required this.onToggleMute, + required this.onFullscreen, }); final Player player; @@ -154,44 +350,118 @@ class _VideoSurface extends StatelessWidget { final bool controlsVisible; final bool coverVideo; final bool muted; + final bool fullscreen; + final String? errorMessage; final VoidCallback onInteract; + final VoidCallback onRetry; final VoidCallback onToggleFit; final VoidCallback onToggleMute; + final VoidCallback onFullscreen; @override Widget build(BuildContext context) { + final child = Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: Colors.black, + child: Video( + controller: controller, + fit: coverVideo ? BoxFit.cover : BoxFit.contain, + controls: null, + ), + ), + StreamBuilder( + stream: player.stream.buffering, + initialData: player.state.buffering, + builder: (context, snapshot) { + if (snapshot.data != true || errorMessage != null) { + return const SizedBox.shrink(); + } + return const Center(child: CircularProgressIndicator()); + }, + ), + if (errorMessage != null) + _VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), + AnimatedOpacity( + opacity: controlsVisible || errorMessage != null ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: _VideoControls( + player: player, + muted: muted, + coverVideo: coverVideo, + fullscreen: fullscreen, + onToggleFit: onToggleFit, + onToggleMute: onToggleMute, + onFullscreen: onFullscreen, + ), + ), + ], + ); + return MouseRegion( onHover: (_) => onInteract(), child: GestureDetector( onTap: onInteract, + onDoubleTap: onFullscreen, child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: AspectRatio( - aspectRatio: aspectRatio.clamp(0.35, 2.4), - child: Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: Colors.black, - child: Video( - controller: controller, - fit: coverVideo ? BoxFit.cover : BoxFit.contain, - controls: null, - ), + borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), + child: fullscreen + ? SizedBox.expand(child: child) + : AspectRatio( + aspectRatio: aspectRatio.clamp(0.35, 2.4), + child: child, ), - AnimatedOpacity( - opacity: controlsVisible ? 1 : 0, - duration: const Duration(milliseconds: 180), - child: _VideoControls( - player: player, - muted: muted, - coverVideo: coverVideo, - onToggleFit: onToggleFit, - onToggleMute: onToggleMute, - ), - ), - ], - ), + ), + ), + ); + } +} + +class _VideoErrorOverlay extends StatelessWidget { + const _VideoErrorOverlay({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black.withValues(alpha: 0.62), + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, + color: Colors.white, size: 42), + const SizedBox(height: 10), + Text( + 'Could not load video', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white), + ), + const SizedBox(height: 6), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ], ), ), ), @@ -204,15 +474,19 @@ class _VideoControls extends StatelessWidget { required this.player, required this.muted, required this.coverVideo, + required this.fullscreen, required this.onToggleFit, required this.onToggleMute, + required this.onFullscreen, }); final Player player; final bool muted; final bool coverVideo; + final bool fullscreen; final VoidCallback onToggleFit; final VoidCallback onToggleMute; + final VoidCallback onFullscreen; @override Widget build(BuildContext context) { @@ -222,8 +496,8 @@ class _VideoControls extends StatelessWidget { begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.black.withValues(alpha: 0.18), + Colors.black.withValues(alpha: 0.78), + Colors.black.withValues(alpha: 0.22), Colors.transparent, ], ), @@ -231,7 +505,7 @@ class _VideoControls extends StatelessWidget { child: Align( alignment: Alignment.bottomCenter, child: Padding( - padding: const EdgeInsets.all(12), + padding: EdgeInsets.all(fullscreen ? 18 : 12), child: StreamBuilder( stream: player.stream.playing, initialData: player.state.playing, @@ -252,55 +526,120 @@ class _VideoControls extends StatelessWidget { final valueMs = position.inMilliseconds .clamp(0, maxMs.toInt()) .toDouble(); - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Slider( - value: valueMs, - max: maxMs, - onChanged: (value) => player.seek( - Duration(milliseconds: value.round()), - ), - ), - Row( + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + return Column( + mainAxisAlignment: MainAxisAlignment.end, children: [ - IconButton.filledTonal( - tooltip: playing ? 'Pause' : 'Play', - onPressed: player.playOrPause, - icon: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + ), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: value.round(), + ), + ), ), ), - const SizedBox(width: 8), - Text( - '${_format(position)} / ${_format(duration)}', - style: const TextStyle(color: Colors.white), - ), - const Spacer(), - IconButton.filledTonal( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: Icon( - muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - ), - const SizedBox(width: 6), - IconButton.filledTonal( - tooltip: coverVideo ? 'Fit' : 'Fill', - onPressed: onToggleFit, - icon: Icon( - coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, - ), + Row( + children: [ + IconButton.filled( + tooltip: playing ? 'Pause' : 'Play', + onPressed: player.playOrPause, + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + '${_format(position)} / ${_format(duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const Spacer(), + if (!compact) ...[ + IconButton.filledTonal( + tooltip: 'Back 10s', + onPressed: () => player.seek( + _clampSeek( + position - + const Duration(seconds: 10), + duration, + ), + ), + icon: const Icon(Icons.replay_10_rounded), + ), + const SizedBox(width: 6), + IconButton.filledTonal( + tooltip: 'Forward 10s', + onPressed: () => player.seek( + _clampSeek( + position + + const Duration(seconds: 10), + duration, + ), + ), + icon: + const Icon(Icons.forward_10_rounded), + ), + const SizedBox(width: 6), + ], + IconButton.filledTonal( + tooltip: muted ? 'Unmute' : 'Mute', + onPressed: onToggleMute, + icon: Icon( + muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + ), + ), + const SizedBox(width: 6), + if (!compact) ...[ + IconButton.filledTonal( + tooltip: coverVideo ? 'Fit' : 'Fill', + onPressed: onToggleFit, + icon: Icon( + coverVideo + ? Icons.fit_screen_rounded + : Icons.crop_free_rounded, + ), + ), + const SizedBox(width: 6), + ], + IconButton.filledTonal( + tooltip: fullscreen + ? 'Exit fullscreen' + : 'Fullscreen', + onPressed: onFullscreen, + icon: Icon( + fullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + ), + ), + ], ), ], - ), - ], + ); + }, ); }, ); @@ -320,4 +659,10 @@ class _VideoControls extends StatelessWidget { if (hours > 0) return '$hours:$minutes:$seconds'; return '$minutes:$seconds'; } + + Duration _clampSeek(Duration position, Duration duration) { + if (position < Duration.zero) return Duration.zero; + if (duration > Duration.zero && position > duration) return duration; + return position; + } }