Files
Prisma/lib/features/post/presentation/widgets/post_media_viewer.dart
T

669 lines
22 KiB
Dart

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<PostMediaViewer> createState() => _PostMediaViewerState();
}
class _PostMediaViewerState extends State<PostMediaViewer> {
Player? _player;
VideoController? _controller;
late List<String> _imageUrls;
late List<String> _videoUrls;
int _imageIndex = 0;
int _videoIndex = 0;
bool _controlsVisible = true;
bool _coverVideo = false;
bool _muted = false;
String? _videoError;
Timer? _hideTimer;
StreamSubscription<String>? _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<void> _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<void> _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<String> _buildImageUrls(Post post) {
return {
post.sampleUrl,
post.fileUrl,
post.previewUrl,
}.where((url) => url.trim().isNotEmpty).toList();
}
List<String> _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<String, String> _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<void> _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<void>(
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<bool>(
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<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
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;
}
}