Improve cross-platform video player
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user