Improve cross-platform video player

This commit is contained in:
2026-05-23 12:42:46 +03:00
parent 00ad16dc51
commit 1fe0969f18
@@ -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<PostMediaViewer> createState() => _PostMediaViewerState();
@@ -20,29 +30,45 @@ 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);
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<PostMediaViewer> {
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<PostMediaViewer> {
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<PostMediaViewer> {
);
}
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,
@@ -110,14 +193,43 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
}.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': '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<PostMediaViewer> {
void _scheduleControlsHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _controlsVisible = false);
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 {
@@ -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,21 +350,17 @@ 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) {
return MouseRegion(
onHover: (_) => onInteract(),
child: GestureDetector(
onTap: onInteract,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AspectRatio(
aspectRatio: aspectRatio.clamp(0.35, 2.4),
child: Stack(
final child = Stack(
fit: StackFit.expand,
children: [
ColoredBox(
@@ -179,21 +371,99 @@ class _VideoSurface extends StatelessWidget {
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 ? 1 : 0,
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'),
),
],
),
),
),
);
}
@@ -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<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
@@ -252,19 +526,34 @@ class _VideoControls extends StatelessWidget {
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: [
Slider(
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8,
),
),
child: Slider(
value: valueMs,
max: maxMs,
onChanged: (value) => player.seek(
Duration(milliseconds: value.round()),
onChanged: duration == Duration.zero
? null
: (value) => player.seek(
Duration(
milliseconds: value.round(),
),
),
),
),
Row(
children: [
IconButton.filledTonal(
IconButton.filled(
tooltip: playing ? 'Pause' : 'Play',
onPressed: player.playOrPause,
icon: Icon(
@@ -273,12 +562,46 @@ class _VideoControls extends StatelessWidget {
: Icons.play_arrow_rounded,
),
),
const SizedBox(width: 8),
Text(
const SizedBox(width: 10),
Flexible(
child: Text(
'${_format(position)} / ${_format(duration)}',
style: const TextStyle(color: Colors.white),
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,
@@ -289,6 +612,7 @@ class _VideoControls extends StatelessWidget {
),
),
const SizedBox(width: 6),
if (!compact) ...[
IconButton.filledTonal(
tooltip: coverVideo ? 'Fit' : 'Fill',
onPressed: onToggleFit,
@@ -298,6 +622,19 @@ class _VideoControls extends StatelessWidget {
: 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,
),
),
],
),
],
@@ -307,6 +644,8 @@ class _VideoControls extends StatelessWidget {
},
);
},
);
},
),
),
),
@@ -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;
}
}