Improved MacOS support and organized screens and utils

This commit is contained in:
nullpeer
2025-11-30 12:49:33 +03:00
parent ae6fd57040
commit d793498d0a
56 changed files with 255 additions and 63 deletions

View File

@@ -0,0 +1,935 @@
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,
),
),
],
),
),
);
}
}