935 lines
30 KiB
Dart
935 lines
30 KiB
Dart
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} |