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, 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(); } 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); _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() { _disposeVideo(); super.dispose(); } @override Widget build(BuildContext context) { if (_controller != null) { return _VideoSurface( player: _player!, controller: _controller!, aspectRatio: widget.post.width > 0 && widget.post.height > 0 ? widget.post.width / widget.post.height : 16 / 9, controlsVisible: _controlsVisible, coverVideo: _coverVideo, muted: _muted, fullscreen: widget.fullscreen, errorMessage: _videoError, onInteract: _showControls, onRetry: _retryVideo, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); _showControls(); }, onToggleMute: () async { final nextMuted = !_muted; setState(() => _muted = nextMuted); 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( key: ValueKey(url), imageUrl: url, httpHeaders: _headersFor(widget.post), fit: BoxFit.contain, placeholder: (context, url) => const Center(child: CircularProgressIndicator()), errorWidget: (context, url, error) { if (_imageIndex < _imageUrls.length - 1) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _imageIndex++); }); return const Center(child: CircularProgressIndicator()); } return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.broken_image_rounded, size: 48), SizedBox(height: 8), Text('Could not load image'), ], ), ); }, ), ); } 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, post.fileUrl, post.previewUrl, }.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': '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/', }; } void _showControls() { if (!mounted) return; setState(() => _controlsVisible = true); _scheduleControlsHide(); } void _scheduleControlsHide() { _hideTimer?.cancel(); _hideTimer = Timer(const Duration(seconds: 3), () { 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 { const _VideoSurface({ required this.player, required this.controller, required this.aspectRatio, 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; final VideoController controller; final double aspectRatio; 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(fullscreen ? 0 : 10), child: fullscreen ? SizedBox.expand(child: child) : AspectRatio( aspectRatio: aspectRatio.clamp(0.35, 2.4), child: child, ), ), ), ); } } 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'), ), ], ), ), ), ); } } class _VideoControls extends StatelessWidget { const _VideoControls({ 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) { return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withValues(alpha: 0.78), Colors.black.withValues(alpha: 0.22), Colors.transparent, ], ), ), child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: EdgeInsets.all(fullscreen ? 18 : 12), child: StreamBuilder( stream: player.stream.playing, initialData: player.state.playing, builder: (context, playingSnapshot) { final playing = playingSnapshot.data ?? false; return StreamBuilder( stream: player.stream.duration, initialData: player.state.duration, builder: (context, durationSnapshot) { final duration = durationSnapshot.data ?? Duration.zero; return StreamBuilder( stream: player.stream.position, initialData: player.state.position, builder: (context, positionSnapshot) { final position = positionSnapshot.data ?? Duration.zero; final maxMs = duration.inMilliseconds.clamp(1, 1 << 31).toDouble(); final valueMs = position.inMilliseconds .clamp(0, maxMs.toInt()) .toDouble(); return LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 560; return Column( mainAxisAlignment: MainAxisAlignment.end, children: [ 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(), ), ), ), ), 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, ), ), ], ), ], ); }, ); }, ); }, ); }, ), ), ), ); } String _format(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); final hours = duration.inHours; 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; } }