import 'dart:async'; import 'dart:io'; 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 'package:url_launcher/url_launcher.dart'; import 'package:webview_windows/webview_windows.dart'; import '../../../../backend/backend.dart'; final Map _playbackMemory = {}; class PostMediaViewer extends StatefulWidget { const PostMediaViewer({ required this.post, this.fullscreen = false, this.initialPosition = Duration.zero, this.autoplay = false, this.initialLoop = false, this.initialMuted = false, this.initialCoverVideo = false, this.initialHalfVolume = false, this.qualityMode = MediaQualityMode.auto, this.onPlaybackSnapshot, super.key, }); final Post post; final bool fullscreen; final Duration initialPosition; final bool autoplay; final bool initialLoop; final bool initialMuted; final bool initialCoverVideo; final bool initialHalfVolume; final MediaQualityMode qualityMode; final ValueChanged? onPlaybackSnapshot; @override State createState() => _PostMediaViewerState(); } class _PostMediaViewerState extends State with AutomaticKeepAliveClientMixin { Player? _player; VideoController? _controller; late List _imageUrls; late List _videoUrls; int _imageIndex = 0; int _videoIndex = 0; bool _controlsVisible = true; bool _inFullscreen = false; late bool _coverVideo; late bool _muted; late bool _loopVideo; late bool _halfVolume; String? _videoError; Timer? _hideTimer; StreamSubscription? _errorSubscription; StreamSubscription? _positionSubscription; StreamSubscription? _playingSubscription; @override void initState() { super.initState(); _coverVideo = widget.initialCoverVideo; _muted = widget.initialMuted; _loopVideo = widget.initialLoop; _halfVolume = widget.initialHalfVolume; _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 bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); if (_isSwf(widget.post)) { return _SwfMediaViewer( post: widget.post, url: _swfUrl(widget.post), fullscreen: widget.fullscreen, ); } if (_controller != null) { if (_inFullscreen) { return ColoredBox( color: Colors.black, child: AspectRatio( aspectRatio: widget.post.width > 0 && widget.post.height > 0 ? (widget.post.width / widget.post.height).clamp(0.35, 2.4) : 16 / 9, child: const Center( child: Icon(Icons.fullscreen_rounded, color: Colors.white38), ), ), ); } return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), }, child: Actions( actions: { _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( onInvoke: (_) { _togglePlay(); return null; }, ), }, child: Focus( autofocus: true, child: _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, loopVideo: _loopVideo, halfVolume: _halfVolume, fullscreen: widget.fullscreen, errorMessage: _videoError, onInteract: _showControls, onRetry: _retryVideo, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); _showControls(); }, onToggleMute: () async { final nextMuted = !_muted; setState(() => _muted = nextMuted); await _applyVolume(); _showControls(); }, onToggleHalfVolume: () async { setState(() { _halfVolume = !_halfVolume; if (_halfVolume) _muted = false; }); await _applyVolume(); _showControls(); }, onToggleLoop: () async { final nextLoop = !_loopVideo; setState(() => _loopVideo = nextLoop); await _player!.setPlaylistMode( nextLoop ? PlaylistMode.single : PlaylistMode.none, ); _showControls(); }, onFullscreen: widget.fullscreen ? () => Navigator.of(context, rootNavigator: true) .pop(_snapshot()) : () => _openFullscreen(context), ), ), ), ); } final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; final image = 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'), ], ), ); }, ); final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; return allowPinchZoom ? InteractiveViewer(child: image) : image; } void _initializeVideo() { MediaKit.ensureInitialized(); _player = Player(); _controller = VideoController(_player!); _applyVolume(); _player!.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); _errorSubscription = _player!.stream.error.listen((message) { if (!mounted) return; if (_videoIndex < _videoUrls.length - 1) { _videoIndex++; _openVideo(play: false); return; } setState(() => _videoError = message); _showControls(); }); _positionSubscription = _player!.stream.position.listen((_) { final snapshot = _snapshot(); _playbackMemory[widget.post.cacheKey] = snapshot; widget.onPlaybackSnapshot?.call(snapshot); }); _playingSubscription = _player!.stream.playing.listen((_) { final snapshot = _snapshot(); _playbackMemory[widget.post.cacheKey] = snapshot; widget.onPlaybackSnapshot?.call(snapshot); }); _openVideo(play: widget.autoplay); _scheduleControlsHide(); } Future _openVideo({required bool play}) async { final player = _player; if (player == null || _videoUrls.isEmpty) return; final remembered = _playbackMemory[widget.post.cacheKey]; final initialPosition = widget.initialPosition > Duration.zero ? widget.initialPosition : remembered?.position ?? Duration.zero; setState(() => _videoError = null); await player.open( Media( _videoUrls[_videoIndex], httpHeaders: _headersFor(widget.post), ), play: false, ); if (initialPosition > Duration.zero) { await player.seek(initialPosition); } if (play) await player.play(); } Future _retryVideo() async { final player = _player; if (player == null) return; setState(() { _videoError = null; _videoIndex = 0; _controlsVisible = true; }); await player.stop(); await _openVideo(play: true); _showControls(); } void _disposeVideo() { _hideTimer?.cancel(); _hideTimer = null; _errorSubscription?.cancel(); _errorSubscription = null; _positionSubscription?.cancel(); _positionSubscription = null; _playingSubscription?.cancel(); _playingSubscription = null; final snapshot = _snapshot(); _playbackMemory[widget.post.cacheKey] = snapshot; widget.onPlaybackSnapshot?.call(snapshot); _player?.dispose(); _player = null; _controller = null; } List _buildImageUrls(Post post) { return MediaUrlSelector.details(post, mode: widget.qualityMode); } List _buildVideoUrls(Post post) { return MediaUrlSelector.video(post); } 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 _isSwf(Post post) { final value = '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' .toLowerCase(); return value.contains('swf') || value.contains('.swf'); } String _swfUrl(Post post) { for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) { final value = url.trim(); if (value.toLowerCase().contains('.swf')) return value; } return post.fileUrl.trim(); } 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 controller = _controller; if (player == null || controller == null) return; setState(() => _inFullscreen = true); if (!context.mounted) return; final result = await Navigator.of(context, rootNavigator: true) .push( PageRouteBuilder( opaque: true, pageBuilder: (_, __, ___) => _FullscreenVideoPage( player: player, controller: controller, aspectRatio: widget.post.width > 0 && widget.post.height > 0 ? widget.post.width / widget.post.height : 16 / 9, loopVideo: _loopVideo, muted: _muted, halfVolume: _halfVolume, coverVideo: _coverVideo, errorMessage: _videoError, onRetry: _retryVideo, ), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, ), ), ); if (mounted) setState(() => _inFullscreen = false); if (result != null) { _playbackMemory[widget.post.cacheKey] = result; setState(() { _muted = result.muted; _loopVideo = result.loopVideo; _coverVideo = result.coverVideo; _halfVolume = result.halfVolume; }); await _applyVolume(); await player.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); return; } } VideoPlaybackSnapshot _snapshot() { final player = _player; return VideoPlaybackSnapshot( position: player?.state.position ?? Duration.zero, playing: player?.state.playing ?? false, muted: _muted, halfVolume: _halfVolume, loopVideo: _loopVideo, coverVideo: _coverVideo, ); } Future _applyVolume() async { await _player?.setVolume(_muted ? 0 : _halfVolume ? 50 : 100); } Future _togglePlay() async { final player = _player; if (player == null) return; if (player.state.playing) { await player.pause(); } else { await player.play(); } _showControls(); } } class _TogglePlayIntent extends Intent { const _TogglePlayIntent(); } class _SwfMediaViewer extends StatelessWidget { const _SwfMediaViewer({ required this.post, required this.url, required this.fullscreen, }); final Post post; final String url; final bool fullscreen; @override Widget build(BuildContext context) { if (url.isEmpty) { return const Center(child: Text('SWF URL is empty')); } if (Platform.isWindows) { return _WindowsRuffleViewer(url: url, fullscreen: fullscreen); } return _SwfUnsupportedPanel(url: url); } } class _WindowsRuffleViewer extends StatefulWidget { const _WindowsRuffleViewer({ required this.url, required this.fullscreen, }); final String url; final bool fullscreen; @override State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState(); } class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> { final _controller = WebviewController(); bool _loading = true; String? _error; @override void initState() { super.initState(); _initialize(); } @override void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.url != widget.url) { _load(); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final child = ClipRRect( borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), child: Stack( fit: StackFit.expand, children: [ if (_controller.value.isInitialized) Webview(_controller), if (_loading) const ColoredBox( color: Colors.black, child: Center(child: CircularProgressIndicator()), ), if (_error != null) _SwfErrorPanel( message: _error!, url: widget.url, onRetry: _load, ), ], ), ); if (widget.fullscreen) return child; return AspectRatio(aspectRatio: 4 / 3, child: child); } Future _initialize() async { try { await _controller.initialize(); _controller.loadingState.listen((state) { if (!mounted) return; setState(() => _loading = state == LoadingState.loading); }); _controller.onLoadError.listen((error) { if (!mounted) return; setState(() => _error = 'Ruffle WebView error: $error'); }); await _load(); } catch (error) { if (!mounted) return; setState(() { _loading = false; _error = 'Could not start Windows WebView2. Install WebView2 Runtime or open original.'; }); } } Future _load() async { if (!_controller.value.isInitialized) return; setState(() { _loading = true; _error = null; }); try { await _controller.loadStringContent(_ruffleHtml(widget.url)); } catch (error) { if (!mounted) return; setState(() { _loading = false; _error = 'Could not load Ruffle viewer: $error'; }); } } String _ruffleHtml(String swfUrl) { final escapedUrl = _htmlEscape(swfUrl); return '''
'''; } String _htmlEscape(String value) { return value .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll("'", ''') .replaceAll('<', '<') .replaceAll('>', '>'); } } class _SwfUnsupportedPanel extends StatelessWidget { const _SwfUnsupportedPanel({required this.url}); final String url; @override Widget build(BuildContext context) { return _SwfErrorPanel( message: Platform.isAndroid ? 'Flash/SWF playback is desktop only.' : 'SWF playback is available on Windows only in this build.', url: url, ); } } class _SwfErrorPanel extends StatelessWidget { const _SwfErrorPanel({ required this.message, required this.url, this.onRetry, }); final String message; final String url; final VoidCallback? onRetry; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return ColoredBox( color: Colors.black, child: Center( child: Padding( padding: const EdgeInsets.all(22), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.extension_rounded, color: Colors.white, size: 48), const SizedBox(height: 12), Text( 'SWF / Flash', style: Theme.of(context) .textTheme .titleLarge ?.copyWith(color: Colors.white), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ if (onRetry != null) FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh_rounded), label: const Text('Retry'), ), FilledButton.tonalIcon( onPressed: () => launchUrl( Uri.parse(url), mode: LaunchMode.externalApplication, ), icon: const Icon(Icons.open_in_new_rounded), label: const Text('Open original'), ), FilledButton.tonalIcon( style: FilledButton.styleFrom( backgroundColor: scheme.primaryContainer.withValues(alpha: 0.82), ), onPressed: () => launchUrl( Uri.parse( 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}', ), mode: LaunchMode.externalApplication, ), icon: const Icon(Icons.play_circle_outline_rounded), label: const Text('Open in Ruffle'), ), ], ), ], ), ), ), ); } } class VideoPlaybackSnapshot { const VideoPlaybackSnapshot({ required this.position, required this.playing, required this.muted, required this.halfVolume, required this.loopVideo, required this.coverVideo, }); final Duration position; final bool playing; final bool muted; final bool halfVolume; final bool loopVideo; final bool coverVideo; } class _FullscreenVideoPage extends StatefulWidget { const _FullscreenVideoPage({ required this.player, required this.controller, required this.aspectRatio, required this.loopVideo, required this.muted, required this.halfVolume, required this.coverVideo, required this.errorMessage, required this.onRetry, }); final Player player; final VideoController controller; final double aspectRatio; final bool loopVideo; final bool muted; final bool halfVolume; final bool coverVideo; final String? errorMessage; final VoidCallback onRetry; @override State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); } class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { bool _controlsVisible = true; late bool _coverVideo; late bool _muted; late bool _loopVideo; late bool _halfVolume; Timer? _hideTimer; @override void initState() { super.initState(); _coverVideo = widget.coverVideo; _muted = widget.muted; _loopVideo = widget.loopVideo; _halfVolume = widget.halfVolume; SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, DeviceOrientation.portraitUp, ]); _scheduleControlsHide(); } @override void dispose() { _hideTimer?.cancel(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setPreferredOrientations(DeviceOrientation.values); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; Navigator.of(context, rootNavigator: true).pop(_snapshot()); }, child: Scaffold( backgroundColor: Colors.black, body: Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), }, child: Actions( actions: { _CloseVideoIntent: CallbackAction<_CloseVideoIntent>( onInvoke: (_) { Navigator.of(context, rootNavigator: true).pop(_snapshot()); return null; }, ), _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( onInvoke: (_) { widget.player.playOrPause(); _showControls(); return null; }, ), }, child: Focus( autofocus: true, child: Stack( children: [ Positioned.fill( child: _VideoSurface( player: widget.player, controller: widget.controller, aspectRatio: widget.aspectRatio, controlsVisible: _controlsVisible, coverVideo: _coverVideo, muted: _muted, loopVideo: _loopVideo, halfVolume: _halfVolume, fullscreen: true, errorMessage: widget.errorMessage, onInteract: _showControls, onRetry: widget.onRetry, onToggleFit: () { setState(() => _coverVideo = !_coverVideo); _showControls(); }, onToggleMute: () async { setState(() => _muted = !_muted); await _applyVolume(); _showControls(); }, onToggleHalfVolume: () async { setState(() { _halfVolume = !_halfVolume; if (_halfVolume) _muted = false; }); await _applyVolume(); _showControls(); }, onToggleLoop: () async { setState(() => _loopVideo = !_loopVideo); await widget.player.setPlaylistMode( _loopVideo ? PlaylistMode.single : PlaylistMode.none, ); _showControls(); }, onFullscreen: () => Navigator.of( context, rootNavigator: true, ).pop(_snapshot()), ), ), Positioned( top: 12, right: 12, child: SafeArea( child: IconButton.filledTonal( tooltip: 'Close fullscreen', onPressed: () => Navigator.of( context, rootNavigator: true, ).pop(_snapshot()), icon: const Icon(Icons.close_rounded), ), ), ), ], ), ), ), ), ), ); } void _showControls() { if (!mounted) return; setState(() => _controlsVisible = true); _scheduleControlsHide(); } void _scheduleControlsHide() { _hideTimer?.cancel(); _hideTimer = Timer(const Duration(seconds: 3), () { if (mounted && widget.errorMessage == null) { setState(() => _controlsVisible = false); } }); } Future _applyVolume() async { await widget.player.setVolume(_muted ? 0 : _halfVolume ? 50 : 100); } VideoPlaybackSnapshot _snapshot() { return VideoPlaybackSnapshot( position: widget.player.state.position, playing: widget.player.state.playing, muted: _muted, halfVolume: _halfVolume, loopVideo: _loopVideo, coverVideo: _coverVideo, ); } } class _CloseVideoIntent extends Intent { const _CloseVideoIntent(); } 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.halfVolume, required this.loopVideo, required this.fullscreen, required this.errorMessage, required this.onInteract, required this.onRetry, required this.onToggleFit, required this.onToggleMute, required this.onToggleHalfVolume, required this.onToggleLoop, required this.onFullscreen, }); final Player player; final VideoController controller; final double aspectRatio; final bool controlsVisible; final bool coverVideo; final bool muted; final bool halfVolume; final bool loopVideo; final bool fullscreen; final String? errorMessage; final VoidCallback onInteract; final VoidCallback onRetry; final VoidCallback onToggleFit; final VoidCallback onToggleMute; final VoidCallback onToggleHalfVolume; final VoidCallback onToggleLoop; 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), if (errorMessage == null) StreamBuilder( stream: player.stream.playing, initialData: player.state.playing, builder: (context, snapshot) { final playing = snapshot.data ?? false; return AnimatedScale( scale: controlsVisible || !playing ? 1 : 0.85, duration: const Duration(milliseconds: 160), child: AnimatedOpacity( opacity: controlsVisible || !playing ? 1 : 0, duration: const Duration(milliseconds: 160), child: Center( child: DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.46), shape: BoxShape.circle, boxShadow: const [ BoxShadow( color: Colors.black45, blurRadius: 24, offset: Offset(0, 10), ), ], ), child: IconButton( tooltip: playing ? 'Pause' : 'Play', iconSize: fullscreen ? 58 : 44, color: Colors.white, onPressed: () { player.playOrPause(); onInteract(); }, icon: Icon( playing ? Icons.pause_rounded : Icons.play_arrow_rounded, ), ), ), ), ), ); }, ), IgnorePointer( ignoring: !controlsVisible || errorMessage != null, child: AnimatedOpacity( opacity: controlsVisible || errorMessage != null ? 1 : 0, duration: const Duration(milliseconds: 180), child: _VideoControls( player: player, muted: muted, halfVolume: halfVolume, loopVideo: loopVideo, coverVideo: coverVideo, fullscreen: fullscreen, onToggleFit: onToggleFit, onToggleMute: onToggleMute, onToggleHalfVolume: onToggleHalfVolume, onToggleLoop: onToggleLoop, onFullscreen: onFullscreen, ), ), ), ], ); return MouseRegion( onHover: (_) => onInteract(), child: GestureDetector( onTap: onInteract, onLongPress: player.playOrPause, 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, super.key, }); 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 _RoundControlButton extends StatelessWidget { const _RoundControlButton({ required this.tooltip, required this.icon, required this.onPressed, this.emphasized = false, this.selected = false, }); final String tooltip; final IconData icon; final VoidCallback onPressed; final bool emphasized; final bool selected; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = emphasized ? scheme.primary : selected ? scheme.primaryContainer.withValues(alpha: 0.96) : Colors.white.withValues(alpha: 0.13); final foreground = emphasized ? scheme.onPrimary : selected ? scheme.onPrimaryContainer : Colors.white; return Tooltip( message: tooltip, child: InkResponse( onTap: onPressed, radius: emphasized ? 23 : 21, child: AnimatedContainer( duration: const Duration(milliseconds: 140), width: emphasized ? 38 : 34, height: emphasized ? 38 : 34, decoration: BoxDecoration( color: background, shape: BoxShape.circle, border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), ), ), ); } } class _VideoControls extends StatelessWidget { const _VideoControls({ required this.player, required this.muted, required this.halfVolume, required this.loopVideo, required this.coverVideo, required this.fullscreen, required this.onToggleFit, required this.onToggleMute, required this.onToggleHalfVolume, required this.onToggleLoop, required this.onFullscreen, }); final Player player; final bool muted; final bool halfVolume; final bool loopVideo; final bool coverVideo; final bool fullscreen; final VoidCallback onToggleFit; final VoidCallback onToggleMute; final VoidCallback onToggleHalfVolume; final VoidCallback onToggleLoop; final VoidCallback onFullscreen; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Align( alignment: Alignment.bottomCenter, child: SafeArea( minimum: EdgeInsets.fromLTRB( fullscreen ? 14 : 8, 0, fullscreen ? 14 : 8, fullscreen ? 10 : 8, ), child: DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.58), borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), child: Padding( padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), 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; final tiny = constraints.maxWidth < 390; return Column( mainAxisSize: MainAxisSize.min, children: [ SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: scheme.primary, inactiveTrackColor: Colors.white.withValues(alpha: 0.24), trackHeight: 3, thumbColor: scheme.primary, overlayColor: scheme.primary.withValues(alpha: 0.18), thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 5, ), ), child: Slider( value: valueMs, max: maxMs, onChanged: duration == Duration.zero ? null : (value) => player.seek( Duration( milliseconds: value.round(), ), ), ), ), Row( children: [ _RoundControlButton( tooltip: playing ? 'Pause' : 'Play', emphasized: true, onPressed: player.playOrPause, icon: playing ? Icons.pause_rounded : Icons.play_arrow_rounded, ), const SizedBox(width: 8), Text( tiny ? _format(position) : '${_format(position)} / ${_format(duration)}', style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w700, ), ), const Spacer(), if (!compact) ...[ _RoundControlButton( tooltip: 'Back 10s', onPressed: () => player.seek( _clampSeek( position - const Duration(seconds: 10), duration, ), ), icon: Icons.replay_10_rounded, ), const SizedBox(width: 5), _RoundControlButton( tooltip: 'Forward 10s', onPressed: () => player.seek( _clampSeek( position + const Duration(seconds: 10), duration, ), ), icon: Icons.forward_10_rounded, ), const SizedBox(width: 5), ], _RoundControlButton( tooltip: muted ? 'Unmute' : 'Mute', onPressed: onToggleMute, icon: muted ? Icons.volume_off_rounded : Icons.volume_up_rounded, ), const SizedBox(width: 5), _RoundControlButton( tooltip: halfVolume ? 'Normal app volume' : 'Half app volume', selected: halfVolume, onPressed: onToggleHalfVolume, icon: Icons.volume_down_rounded, ), const SizedBox(width: 5), _RoundControlButton( tooltip: loopVideo ? 'Disable repeat' : 'Repeat video', selected: loopVideo, onPressed: onToggleLoop, icon: loopVideo ? Icons.repeat_one_on_rounded : Icons.repeat_one_rounded, ), const SizedBox(width: 5), if (!compact) ...[ _RoundControlButton( tooltip: coverVideo ? 'Fit' : 'Fill', selected: coverVideo, onPressed: onToggleFit, icon: coverVideo ? Icons.fit_screen_rounded : Icons.crop_free_rounded, ), const SizedBox(width: 5), ], _RoundControlButton( tooltip: fullscreen ? 'Exit fullscreen' : 'Fullscreen', onPressed: onFullscreen, 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; } }