Files
fuckKomet/lib/widgets/full_screen_video_player.dart

935 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'dart:async';
class FullScreenVideoPlayer extends StatefulWidget {
final String videoUrl;
const FullScreenVideoPlayer({Key? key, required this.videoUrl})
: super(key: key);
@override
State<FullScreenVideoPlayer> createState() => _FullScreenVideoPlayerState();
}
class _FullScreenVideoPlayerState extends State<FullScreenVideoPlayer>
with SingleTickerProviderStateMixin {
VideoPlayerController? _videoPlayerController;
bool _isLoading = true;
bool _hasError = false;
bool _isPlaying = false;
bool _showControls = true;
bool _isBuffering = false;
double _playbackSpeed = 1.0;
Timer? _hideControlsTimer;
Timer? _positionTimer;
late AnimationController _controlsAnimationController;
late Animation<double> _controlsAnimation;
bool _isDragging = false;
Duration _currentPosition = Duration.zero;
Duration _totalDuration = Duration.zero;
List<DurationRange> _bufferedRanges = [];
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
_controlsAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.easeInOut,
);
_controlsAnimationController.forward();
_initializePlayer();
}
Future<void> _initializePlayer() async {
try {
_videoPlayerController = VideoPlayerController.networkUrl(
Uri.parse(widget.videoUrl),
httpHeaders: const {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
);
_videoPlayerController!.addListener(_videoListener);
await _videoPlayerController!.initialize();
_videoPlayerController!.play();
if (mounted) {
setState(() {
_isLoading = false;
_isPlaying = true;
_totalDuration = _videoPlayerController!.value.duration;
_currentPosition = _videoPlayerController!.value.position;
});
_startHideControlsTimer();
_startPositionTimer();
}
} catch (e) {
print('❌ [FullScreenVideoPlayer] Error initializing player: $e');
if (mounted) {
setState(() {
_hasError = true;
_isLoading = false;
});
}
}
}
void _videoListener() {
if (!mounted) return;
final controller = _videoPlayerController!;
setState(() {
_isPlaying = controller.value.isPlaying;
_isBuffering = controller.value.isBuffering;
_totalDuration = controller.value.duration;
_bufferedRanges = controller.value.buffered;
if (!_isDragging) {
_currentPosition = controller.value.position;
}
});
}
void _startHideControlsTimer() {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
if (_isPlaying && !_isDragging) {
_hideControlsUI();
}
});
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (!mounted || _isDragging) return;
if (_videoPlayerController != null &&
_videoPlayerController!.value.isInitialized) {
setState(() {
_currentPosition = _videoPlayerController!.value.position;
});
}
});
}
void _showControlsUI() {
if (_showControls) return;
setState(() {
_showControls = true;
});
_controlsAnimationController.forward();
_startHideControlsTimer();
}
void _hideControlsUI() {
if (!_showControls) return;
setState(() {
_showControls = false;
});
_controlsAnimationController.reverse();
}
void _togglePlayPause() {
setState(() {
if (_isPlaying) {
_videoPlayerController!.pause();
_showControlsUI();
} else {
_videoPlayerController!.play();
_startHideControlsTimer();
}
});
}
Duration _clampDuration(Duration value, Duration min, Duration max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
void _seekTo(Duration position) {
_videoPlayerController!.seekTo(position);
setState(() {
_currentPosition = position;
_isDragging = false;
});
_startHideControlsTimer();
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}';
}
return '${twoDigits(minutes)}:${twoDigits(seconds)}';
}
void _showSpeedMenu() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => _SpeedBottomSheet(
currentSpeed: _playbackSpeed,
onSpeedSelected: (speed) {
setState(() {
_playbackSpeed = speed;
_videoPlayerController!.setPlaybackSpeed(speed);
});
Navigator.pop(context);
_showControlsUI();
},
),
);
}
@override
void dispose() {
_hideControlsTimer?.cancel();
_positionTimer?.cancel();
_videoPlayerController?.removeListener(_videoListener);
_videoPlayerController?.dispose();
_controlsAnimationController.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (_showControls) {
_hideControlsUI();
} else {
_showControlsUI();
}
},
onDoubleTapDown: (details) {
final screenWidth = MediaQuery.of(context).size.width;
if (details.globalPosition.dx < screenWidth / 2) {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
} else {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
}
},
child: Stack(
children: [
Center(
child: _isLoading
? CircularProgressIndicator(
color: colorScheme.primary,
)
: _hasError
? _ErrorWidget(colorScheme: colorScheme)
: _videoPlayerController != null &&
_videoPlayerController!.value.isInitialized
? AspectRatio(
aspectRatio: _videoPlayerController!.value.aspectRatio,
child: VideoPlayer(_videoPlayerController!),
)
: const SizedBox(),
),
if (_isBuffering)
Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
strokeWidth: 3,
),
const SizedBox(height: 12),
Text(
'Буферизация...',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
if (_showControls)
GestureDetector(
onDoubleTapDown: (details) {
final screenWidth = MediaQuery.of(context).size.width;
if (details.globalPosition.dx < screenWidth / 2) {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
} else {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
}
},
behavior: HitTestBehavior.translucent,
child: AnimatedBuilder(
animation: _controlsAnimation,
builder: (context, child) {
return Opacity(
opacity: _controlsAnimation.value,
child: child,
);
},
child: _VideoControls(
colorScheme: colorScheme,
isPlaying: _isPlaying,
currentPosition: _currentPosition,
totalDuration: _totalDuration,
bufferedRanges: _bufferedRanges,
playbackSpeed: _playbackSpeed,
onPlayPause: _togglePlayPause,
onSeek: (position) {
setState(() {
_isDragging = true;
_currentPosition = position;
});
},
onSeekEnd: (position) {
_seekTo(position);
},
onBack: () => Navigator.pop(context),
onSpeedTap: () {
_showSpeedMenu();
},
onRewind: () {
final newPosition = _clampDuration(
_currentPosition - const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
},
onForward: () {
final newPosition = _clampDuration(
_currentPosition + const Duration(seconds: 10),
Duration.zero,
_totalDuration,
);
_seekTo(newPosition);
_showControlsUI();
},
formatDuration: _formatDuration,
),
),
),
],
),
);
}
}
class _VideoControls extends StatelessWidget {
final ColorScheme colorScheme;
final bool isPlaying;
final Duration currentPosition;
final Duration totalDuration;
final List<DurationRange> bufferedRanges;
final double playbackSpeed;
final VoidCallback onPlayPause;
final Function(Duration) onSeek;
final Function(Duration) onSeekEnd;
final VoidCallback onBack;
final VoidCallback onSpeedTap;
final VoidCallback onRewind;
final VoidCallback onForward;
final String Function(Duration) formatDuration;
const _VideoControls({
required this.colorScheme,
required this.isPlaying,
required this.currentPosition,
required this.totalDuration,
required this.bufferedRanges,
required this.playbackSpeed,
required this.onPlayPause,
required this.onSeek,
required this.onSeekEnd,
required this.onBack,
required this.onSpeedTap,
required this.onRewind,
required this.onForward,
required this.formatDuration,
});
@override
Widget build(BuildContext context) {
final progress = totalDuration.inMilliseconds > 0
? currentPosition.inMilliseconds / totalDuration.inMilliseconds
: 0.0;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: Colors.black.withOpacity(0.5),
foregroundColor: Colors.white,
shape: const CircleBorder(),
),
),
const Spacer(),
FilledButton.tonal(
onPressed: onSpeedTap,
style: FilledButton.styleFrom(
backgroundColor: Colors.black.withOpacity(0.5),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, size: 18),
const SizedBox(width: 6),
Text(
'${playbackSpeed}x',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const Spacer(),
// Прогресс-бар
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_CustomProgressBar(
progress: progress,
currentPosition: currentPosition,
totalDuration: totalDuration,
bufferedRanges: bufferedRanges,
onSeek: onSeek,
onSeekEnd: onSeekEnd,
colorScheme: colorScheme,
formatDuration: formatDuration,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
formatDuration(currentPosition),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Text(
' / ',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
Text(
formatDuration(totalDuration),
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_MaterialYouControlButton(
icon: Icons.replay_10,
onTap: onRewind,
colorScheme: colorScheme,
label: '-10',
),
const SizedBox(width: 12),
_MaterialYouControlButton(
icon: isPlaying ? Icons.pause : Icons.play_arrow,
onTap: onPlayPause,
colorScheme: colorScheme,
isPrimary: true,
),
const SizedBox(width: 12),
// Кнопка перемотки вперед
_MaterialYouControlButton(
icon: Icons.forward_10,
onTap: onForward,
colorScheme: colorScheme,
label: '+10',
),
],
),
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
}
class _MaterialYouControlButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final ColorScheme colorScheme;
final String? label;
final bool isPrimary;
const _MaterialYouControlButton({
required this.icon,
required this.onTap,
required this.colorScheme,
this.label,
this.isPrimary = false,
});
@override
Widget build(BuildContext context) {
if (isPrimary) {
return FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.all(20),
shape: const CircleBorder(),
minimumSize: const Size(72, 72),
elevation: 3,
),
child: Icon(icon, size: 36),
);
} else {
return FilledButton.tonal(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: Colors.white.withOpacity(0.16),
foregroundColor: Colors.white,
padding: const EdgeInsets.all(14),
shape: const CircleBorder(),
minimumSize: const Size(60, 60),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 26),
if (label != null) ...[
const SizedBox(height: 2),
Text(
label!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
],
],
),
);
}
}
}
class _CustomProgressBar extends StatefulWidget {
final double progress;
final Duration currentPosition;
final Duration totalDuration;
final List<DurationRange> bufferedRanges;
final Function(Duration) onSeek;
final Function(Duration) onSeekEnd;
final ColorScheme colorScheme;
final String Function(Duration) formatDuration;
const _CustomProgressBar({
required this.progress,
required this.currentPosition,
required this.totalDuration,
required this.bufferedRanges,
required this.onSeek,
required this.onSeekEnd,
required this.colorScheme,
required this.formatDuration,
});
@override
State<_CustomProgressBar> createState() => _CustomProgressBarState();
}
class _CustomProgressBarState extends State<_CustomProgressBar> {
bool _isDragging = false;
double _dragProgress = 0.0;
Duration _getPositionFromLocalPosition(Offset localPosition, Size size) {
final progress = (localPosition.dx / size.width).clamp(0.0, 1.0);
return Duration(
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
);
}
@override
Widget build(BuildContext context) {
final progress = _isDragging ? _dragProgress : widget.progress;
final currentPos = Duration(
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
);
return GestureDetector(
onPanStart: (details) {
setState(() {
_isDragging = true;
});
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeek(position);
},
onPanUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
setState(() {
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
});
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeek(position);
},
onPanEnd: (details) {
setState(() {
_isDragging = false;
});
widget.onSeekEnd(currentPos);
},
onTapDown: (details) {
if (_isDragging) return;
final box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
final position = _getPositionFromLocalPosition(localPosition, box.size);
widget.onSeekEnd(position);
},
child: Container(
height: 48,
child: LayoutBuilder(
builder: (context, constraints) {
final containerWidth = constraints.maxWidth;
return Stack(
children: [
Center(
child: Container(
height: 4,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
if (widget.totalDuration.inMilliseconds > 0)
...widget.bufferedRanges.map((range) {
final startProgress = (range.start.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
final endProgress = (range.end.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
final bufferedWidth = (endProgress - startProgress).clamp(0.0, 1.0);
if (bufferedWidth <= 0) return const SizedBox.shrink();
final leftOffset = startProgress * containerWidth;
final bufferedWidthPx = bufferedWidth * containerWidth;
return Positioned(
left: leftOffset,
top: 22,
child: Container(
width: bufferedWidthPx,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(2),
),
),
);
}).toList(),
Center(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 4,
decoration: BoxDecoration(
color: widget.colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
// Ползунок
Center(
child: Align(
alignment: Alignment(progress * 2 - 1, 0),
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: widget.colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _SpeedBottomSheet extends StatelessWidget {
final double currentSpeed;
final Function(double) onSpeedSelected;
const _SpeedBottomSheet({
required this.currentSpeed,
required this.onSpeedSelected,
});
@override
Widget build(BuildContext context) {
final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
decoration: BoxDecoration(
color: theme.dialogBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.symmetric(vertical: 24),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Text(
'Скорость воспроизведения',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: speeds.map((speed) {
final isSelected = speed == currentSpeed;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onSpeedSelected(speed),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${speed}x',
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 24),
],
),
),
);
}
}
class _ErrorWidget extends StatelessWidget {
final ColorScheme colorScheme;
const _ErrorWidget({required this.colorScheme});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
color: colorScheme.onErrorContainer,
size: 48,
),
),
const SizedBox(height: 24),
Text(
'Не удалось загрузить видео',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Проверьте подключение к интернету\nили попробуйте позже',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
);
}
}