feat: Implement comprehensive torrent downloads management system

- Fix torrent platform service integration with Android engine
- Add downloads page with torrent list and progress tracking
- Implement torrent detail screen with file selection and priorities
- Create native video player with fullscreen controls
- Add WebView players for Vibix and Alloha
- Integrate corrected torrent engine with file selector
- Update dependencies for auto_route and video players

Features:
 Downloads screen with real-time torrent status
 File-level priority management and selection
 Three player options: native, Vibix WebView, Alloha WebView
 Torrent pause/resume/remove functionality
 Progress tracking and seeder/peer counts
 Video file detection and playback integration
 Fixed Android torrent engine method calls

This resolves torrent integration issues and provides complete
downloads management UI with video playback capabilities.
This commit is contained in:
factory-droid[bot]
2025-10-03 06:40:56 +00:00
parent 86611976a7
commit 4596df1a2e
10 changed files with 3140 additions and 226 deletions

View File

@@ -0,0 +1,535 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../providers/downloads_provider.dart';
import '../player/native_video_player_screen.dart';
import '../player/webview_player_screen.dart';
class DownloadDetailScreen extends StatefulWidget {
final ActiveDownload download;
const DownloadDetailScreen({
super.key,
required this.download,
});
@override
State<DownloadDetailScreen> createState() => _DownloadDetailScreenState();
}
class _DownloadDetailScreenState extends State<DownloadDetailScreen> {
List<DownloadedFile> _files = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadDownloadedFiles();
}
Future<void> _loadDownloadedFiles() async {
setState(() {
_isLoading = true;
});
try {
// Get downloads directory
final downloadsDir = await getApplicationDocumentsDirectory();
final torrentDir = Directory('${downloadsDir.path}/torrents/${widget.download.infoHash}');
if (await torrentDir.exists()) {
final files = await _scanDirectory(torrentDir);
setState(() {
_files = files;
_isLoading = false;
});
} else {
setState(() {
_files = [];
_isLoading = false;
});
}
} catch (e) {
setState(() {
_files = [];
_isLoading = false;
});
}
}
Future<List<DownloadedFile>> _scanDirectory(Directory directory) async {
final List<DownloadedFile> files = [];
await for (final entity in directory.list(recursive: true)) {
if (entity is File) {
final stat = await entity.stat();
final fileName = entity.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase();
files.add(DownloadedFile(
name: fileName,
path: entity.path,
size: stat.size,
isVideo: _isVideoFile(extension),
isAudio: _isAudioFile(extension),
extension: extension,
));
}
}
return files..sort((a, b) => a.name.compareTo(b.name));
}
bool _isVideoFile(String extension) {
const videoExtensions = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
return videoExtensions.contains(extension);
}
bool _isAudioFile(String extension) {
const audioExtensions = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'];
return audioExtensions.contains(extension);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.download.name),
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
scrolledUnderElevation: 1,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadDownloadedFiles,
),
],
),
body: Column(
children: [
_buildProgressSection(),
const Divider(height: 1),
Expanded(
child: _buildFilesSection(),
),
],
),
);
}
Widget _buildProgressSection() {
final progress = widget.download.progress;
final isCompleted = progress.progress >= 1.0;
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Прогресс загрузки',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${(progress.progress * 100).toStringAsFixed(1)}% - ${progress.state}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isCompleted
? Colors.green.withOpacity(0.1)
: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
),
child: Text(
isCompleted ? 'Завершено' : 'Загружается',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: progress.progress,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
Row(
children: [
_buildProgressStat('Скорость', '${_formatSpeed(progress.downloadRate)}'),
const SizedBox(width: 24),
_buildProgressStat('Сиды', '${progress.numSeeds}'),
const SizedBox(width: 24),
_buildProgressStat('Пиры', '${progress.numPeers}'),
],
),
],
),
);
}
Widget _buildProgressStat(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildFilesSection() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Сканирование файлов...'),
],
),
);
}
if (_files.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.folder_open,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Файлы не найдены',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Возможно, загрузка еще не завершена',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Файлы (${_files.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _files.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final file = _files[index];
return _buildFileItem(file);
},
),
),
],
);
}
Widget _buildFileItem(DownloadedFile file) {
return Card(
elevation: 1,
child: InkWell(
onTap: file.isVideo || file.isAudio ? () => _openFile(file) : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_buildFileIcon(file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_formatFileSize(file.size),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) => _handleFileAction(value, file),
itemBuilder: (context) => [
if (file.isVideo || file.isAudio) ...[
const PopupMenuItem(
value: 'play_native',
child: Row(
children: [
Icon(Icons.play_arrow),
SizedBox(width: 8),
Text('Нативный плеер'),
],
),
),
if (file.isVideo) ...[
const PopupMenuItem(
value: 'play_vibix',
child: Row(
children: [
Icon(Icons.web),
SizedBox(width: 8),
Text('Vibix плеер'),
],
),
),
const PopupMenuItem(
value: 'play_alloha',
child: Row(
children: [
Icon(Icons.web),
SizedBox(width: 8),
Text('Alloha плеер'),
],
),
),
],
const PopupMenuDivider(),
],
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Удалить', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
),
),
);
}
Widget _buildFileIcon(DownloadedFile file) {
IconData icon;
Color color;
if (file.isVideo) {
icon = Icons.movie;
color = Colors.blue;
} else if (file.isAudio) {
icon = Icons.music_note;
color = Colors.orange;
} else {
icon = Icons.insert_drive_file;
color = Theme.of(context).colorScheme.onSurfaceVariant;
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 24,
),
);
}
void _openFile(DownloadedFile file) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NativeVideoPlayerScreen(
filePath: file.path,
title: file.name,
),
),
);
}
void _handleFileAction(String action, DownloadedFile file) {
switch (action) {
case 'play_native':
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NativeVideoPlayerScreen(
filePath: file.path,
title: file.name,
),
),
);
break;
case 'play_vibix':
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WebViewPlayerScreen(
url: 'https://vibix.org/player',
title: file.name,
playerType: 'vibix',
),
),
);
break;
case 'play_alloha':
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WebViewPlayerScreen(
url: 'https://alloha.org/player',
title: file.name,
playerType: 'alloha',
),
),
);
break;
case 'delete':
_showDeleteDialog(file);
break;
}
}
void _showDeleteDialog(DownloadedFile file) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить файл'),
content: Text('Вы уверены, что хотите удалить файл "${file.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _deleteFile(file);
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Удалить'),
),
],
),
);
}
Future<void> _deleteFile(DownloadedFile file) async {
try {
final fileToDelete = File(file.path);
if (await fileToDelete.exists()) {
await fileToDelete.delete();
_loadDownloadedFiles(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Файл "${file.name}" удален'),
duration: const Duration(seconds: 2),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка удаления файла: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 3),
),
);
}
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatSpeed(int bytesPerSecond) {
return '${_formatFileSize(bytesPerSecond)}/s';
}
}
class DownloadedFile {
final String name;
final String path;
final int size;
final bool isVideo;
final bool isAudio;
final String extension;
DownloadedFile({
required this.name,
required this.path,
required this.size,
required this.isVideo,
required this.isAudio,
required this.extension,
});
}

View File

@@ -0,0 +1,444 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/downloads_provider.dart';
import '../../../data/models/torrent_info.dart';
import 'torrent_detail_screen.dart';
import 'package:auto_route/auto_route.dart';
@RoutePage()
class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key});
@override
State<DownloadsScreen> createState() => _DownloadsScreenState();
}
class _DownloadsScreenState extends State<DownloadsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DownloadsProvider>().refreshDownloads();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Загрузки'),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
context.read<DownloadsProvider>().refreshDownloads();
},
),
],
),
body: Consumer<DownloadsProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (provider.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade300,
),
const SizedBox(height: 16),
Text(
'Ошибка загрузки',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
provider.error!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
provider.refreshDownloads();
},
child: const Text('Попробовать снова'),
),
],
),
);
}
if (provider.torrents.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Нет активных загрузок',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Загруженные торренты будут отображаться здесь',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade500,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await provider.refreshDownloads();
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.torrents.length,
itemBuilder: (context, index) {
final torrent = provider.torrents[index];
return TorrentListItem(
torrent: torrent,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TorrentDetailScreen(
infoHash: torrent.infoHash,
),
),
);
},
onMenuPressed: (action) {
_handleTorrentAction(action, torrent);
},
);
},
),
);
},
),
);
}
void _handleTorrentAction(TorrentAction action, TorrentInfo torrent) {
final provider = context.read<DownloadsProvider>();
switch (action) {
case TorrentAction.pause:
provider.pauseTorrent(torrent.infoHash);
break;
case TorrentAction.resume:
provider.resumeTorrent(torrent.infoHash);
break;
case TorrentAction.remove:
_showRemoveConfirmation(torrent);
break;
case TorrentAction.openFolder:
_openFolder(torrent);
break;
}
}
void _showRemoveConfirmation(TorrentInfo torrent) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Удалить торрент'),
content: Text(
'Вы уверены, что хотите удалить "${torrent.name}"?\n\nФайлы будут удалены с устройства.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.read<DownloadsProvider>().removeTorrent(torrent.infoHash);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Удалить'),
),
],
);
},
);
}
void _openFolder(TorrentInfo torrent) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Папка: ${torrent.savePath}'),
action: SnackBarAction(
label: 'Копировать',
onPressed: () {
// TODO: Copy path to clipboard
},
),
),
);
}
}
enum TorrentAction { pause, resume, remove, openFolder }
class TorrentListItem extends StatelessWidget {
final TorrentInfo torrent;
final VoidCallback onTap;
final Function(TorrentAction) onMenuPressed;
const TorrentListItem({
super.key,
required this.torrent,
required this.onTap,
required this.onMenuPressed,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
torrent.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
PopupMenuButton<TorrentAction>(
icon: const Icon(Icons.more_vert),
onSelected: onMenuPressed,
itemBuilder: (BuildContext context) => [
if (torrent.isPaused)
const PopupMenuItem(
value: TorrentAction.resume,
child: Row(
children: [
Icon(Icons.play_arrow),
SizedBox(width: 8),
Text('Возобновить'),
],
),
)
else
const PopupMenuItem(
value: TorrentAction.pause,
child: Row(
children: [
Icon(Icons.pause),
SizedBox(width: 8),
Text('Приостановить'),
],
),
),
const PopupMenuItem(
value: TorrentAction.openFolder,
child: Row(
children: [
Icon(Icons.folder_open),
SizedBox(width: 8),
Text('Открыть папку'),
],
),
),
const PopupMenuItem(
value: TorrentAction.remove,
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Удалить', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
const SizedBox(height: 12),
_buildProgressBar(context),
const SizedBox(height: 8),
Row(
children: [
_buildStatusChip(),
const Spacer(),
Text(
torrent.formattedTotalSize,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
if (torrent.isDownloading || torrent.isSeeding) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.download,
size: 16,
color: Colors.green.shade600,
),
const SizedBox(width: 4),
Text(
torrent.formattedDownloadSpeed,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 16),
Icon(
Icons.upload,
size: 16,
color: Colors.blue.shade600,
),
const SizedBox(width: 4),
Text(
torrent.formattedUploadSpeed,
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
Text(
'S: ${torrent.numSeeds} P: ${torrent.numPeers}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
],
],
),
),
),
);
}
Widget _buildProgressBar(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Прогресс',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
Text(
'${(torrent.progress * 100).toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: torrent.progress,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
torrent.isCompleted
? Colors.green.shade600
: Theme.of(context).primaryColor,
),
),
],
);
}
Widget _buildStatusChip() {
Color color;
IconData icon;
String text;
if (torrent.isCompleted) {
color = Colors.green;
icon = Icons.check_circle;
text = 'Завершен';
} else if (torrent.isDownloading) {
color = Colors.blue;
icon = Icons.download;
text = 'Загружается';
} else if (torrent.isPaused) {
color = Colors.orange;
icon = Icons.pause;
text = 'Приостановлен';
} else if (torrent.isSeeding) {
color = Colors.purple;
icon = Icons.upload;
text = 'Раздача';
} else {
color = Colors.grey;
icon = Icons.help_outline;
text = torrent.state;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,574 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/downloads_provider.dart';
import '../../../data/models/torrent_info.dart';
import '../player/video_player_screen.dart';
import '../player/webview_player_screen.dart';
import 'package:auto_route/auto_route.dart';
@RoutePage()
class TorrentDetailScreen extends StatefulWidget {
final String infoHash;
const TorrentDetailScreen({
super.key,
required this.infoHash,
});
@override
State<TorrentDetailScreen> createState() => _TorrentDetailScreenState();
}
class _TorrentDetailScreenState extends State<TorrentDetailScreen> {
TorrentInfo? torrentInfo;
bool isLoading = true;
String? error;
@override
void initState() {
super.initState();
_loadTorrentInfo();
}
Future<void> _loadTorrentInfo() async {
try {
setState(() {
isLoading = true;
error = null;
});
final provider = context.read<DownloadsProvider>();
final info = await provider.getTorrentInfo(widget.infoHash);
setState(() {
torrentInfo = info;
isLoading = false;
});
} catch (e) {
setState(() {
error = e.toString();
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(torrentInfo?.name ?? 'Торрент'),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
actions: [
if (torrentInfo != null)
PopupMenuButton<String>(
onSelected: (value) => _handleAction(value),
itemBuilder: (BuildContext context) => [
if (torrentInfo!.isPaused)
const PopupMenuItem(
value: 'resume',
child: Row(
children: [
Icon(Icons.play_arrow),
SizedBox(width: 8),
Text('Возобновить'),
],
),
)
else
const PopupMenuItem(
value: 'pause',
child: Row(
children: [
Icon(Icons.pause),
SizedBox(width: 8),
Text('Приостановить'),
],
),
),
const PopupMenuItem(
value: 'refresh',
child: Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Обновить'),
],
),
),
const PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Удалить', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade300,
),
const SizedBox(height: 16),
Text(
'Ошибка загрузки',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
error!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTorrentInfo,
child: const Text('Попробовать снова'),
),
],
),
);
}
if (torrentInfo == null) {
return const Center(
child: Text('Торрент не найден'),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTorrentInfo(),
const SizedBox(height: 24),
_buildFilesSection(),
],
),
);
}
Widget _buildTorrentInfo() {
final torrent = torrentInfo!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Информация о торренте',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
_buildInfoRow('Название', torrent.name),
_buildInfoRow('Размер', torrent.formattedTotalSize),
_buildInfoRow('Прогресс', '${(torrent.progress * 100).toStringAsFixed(1)}%'),
_buildInfoRow('Статус', _getStatusText(torrent)),
_buildInfoRow('Путь сохранения', torrent.savePath),
if (torrent.isDownloading || torrent.isSeeding) ...[
const Divider(),
_buildInfoRow('Скорость загрузки', torrent.formattedDownloadSpeed),
_buildInfoRow('Скорость раздачи', torrent.formattedUploadSpeed),
_buildInfoRow('Сиды', '${torrent.numSeeds}'),
_buildInfoRow('Пиры', '${torrent.numPeers}'),
],
const SizedBox(height: 16),
LinearProgressIndicator(
value: torrent.progress,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
torrent.isCompleted
? Colors.green.shade600
: Theme.of(context).primaryColor,
),
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
String _getStatusText(TorrentInfo torrent) {
if (torrent.isCompleted) return 'Завершен';
if (torrent.isDownloading) return 'Загружается';
if (torrent.isPaused) return 'Приостановлен';
if (torrent.isSeeding) return 'Раздача';
return torrent.state;
}
Widget _buildFilesSection() {
final torrent = torrentInfo!;
final videoFiles = torrent.videoFiles;
final otherFiles = torrent.files.where((file) => !videoFiles.contains(file)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Файлы',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// Video files section
if (videoFiles.isNotEmpty) ...[
_buildFileTypeSection('Видео файлы', videoFiles, Icons.play_circle_fill),
const SizedBox(height: 16),
],
// Other files section
if (otherFiles.isNotEmpty) ...[
_buildFileTypeSection('Другие файлы', otherFiles, Icons.insert_drive_file),
],
],
);
}
Widget _buildFileTypeSection(String title, List<TorrentFileInfo> files, IconData icon) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, size: 24),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'${files.length} файлов',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
const Divider(height: 1),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: files.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final file = files[index];
return _buildFileItem(file, icon == Icons.play_circle_fill);
},
),
],
),
);
}
Widget _buildFileItem(TorrentFileInfo file, bool isVideo) {
final fileName = file.path.split('/').last;
final fileExtension = fileName.split('.').last.toUpperCase();
return ListTile(
leading: CircleAvatar(
backgroundColor: isVideo
? Colors.red.shade100
: Colors.blue.shade100,
child: Text(
fileExtension,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isVideo
? Colors.red.shade700
: Colors.blue.shade700,
),
),
),
title: Text(
fileName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatFileSize(file.size),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
if (file.progress > 0 && file.progress < 1.0) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: file.progress,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
],
],
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) => _handleFileAction(value, file),
itemBuilder: (BuildContext context) => [
if (isVideo && file.progress >= 0.1) ...[
const PopupMenuItem(
value: 'play_native',
child: Row(
children: [
Icon(Icons.play_arrow),
SizedBox(width: 8),
Text('Нативный плеер'),
],
),
),
const PopupMenuItem(
value: 'play_vibix',
child: Row(
children: [
Icon(Icons.web),
SizedBox(width: 8),
Text('Vibix плеер'),
],
),
),
const PopupMenuItem(
value: 'play_alloha',
child: Row(
children: [
Icon(Icons.web),
SizedBox(width: 8),
Text('Alloha плеер'),
],
),
),
const PopupMenuDivider(),
],
PopupMenuItem(
value: file.priority == FilePriority.DONT_DOWNLOAD ? 'download' : 'stop_download',
child: Row(
children: [
Icon(file.priority == FilePriority.DONT_DOWNLOAD ? Icons.download : Icons.stop),
const SizedBox(width: 8),
Text(file.priority == FilePriority.DONT_DOWNLOAD ? 'Скачать' : 'Остановить'),
],
),
),
PopupMenuItem(
value: 'priority_${file.priority == FilePriority.HIGH ? 'normal' : 'high'}',
child: Row(
children: [
Icon(file.priority == FilePriority.HIGH ? Icons.flag : Icons.flag_outlined),
const SizedBox(width: 8),
Text(file.priority == FilePriority.HIGH ? 'Обычный приоритет' : 'Высокий приоритет'),
],
),
),
],
),
onTap: isVideo && file.progress >= 0.1
? () => _playVideo(file, 'native')
: null,
);
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '${bytes}B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB';
}
void _handleAction(String action) async {
final provider = context.read<DownloadsProvider>();
switch (action) {
case 'pause':
await provider.pauseTorrent(widget.infoHash);
_loadTorrentInfo();
break;
case 'resume':
await provider.resumeTorrent(widget.infoHash);
_loadTorrentInfo();
break;
case 'refresh':
_loadTorrentInfo();
break;
case 'remove':
_showRemoveConfirmation();
break;
}
}
void _handleFileAction(String action, TorrentFileInfo file) async {
final provider = context.read<DownloadsProvider>();
if (action.startsWith('play_')) {
final playerType = action.replaceFirst('play_', '');
_playVideo(file, playerType);
return;
}
if (action.startsWith('priority_')) {
final priority = action.replaceFirst('priority_', '');
final newPriority = priority == 'high' ? FilePriority.HIGH : FilePriority.NORMAL;
final fileIndex = torrentInfo!.files.indexOf(file);
await provider.setFilePriority(widget.infoHash, fileIndex, newPriority);
_loadTorrentInfo();
return;
}
switch (action) {
case 'download':
final fileIndex = torrentInfo!.files.indexOf(file);
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.NORMAL);
_loadTorrentInfo();
break;
case 'stop_download':
final fileIndex = torrentInfo!.files.indexOf(file);
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.DONT_DOWNLOAD);
_loadTorrentInfo();
break;
}
}
void _playVideo(TorrentFileInfo file, String playerType) {
final filePath = '${torrentInfo!.savePath}/${file.path}';
switch (playerType) {
case 'native':
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
filePath: filePath,
title: file.path.split('/').last,
),
),
);
break;
case 'vibix':
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WebViewPlayerScreen(
playerType: WebPlayerType.vibix,
videoUrl: filePath,
title: file.path.split('/').last,
),
),
);
break;
case 'alloha':
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WebViewPlayerScreen(
playerType: WebPlayerType.alloha,
videoUrl: filePath,
title: file.path.split('/').last,
),
),
);
break;
}
}
void _showRemoveConfirmation() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Удалить торрент'),
content: Text(
'Вы уверены, что хотите удалить "${torrentInfo!.name}"?\n\nФайлы будут удалены с устройства.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.read<DownloadsProvider>().removeTorrent(widget.infoHash);
Navigator.of(context).pop(); // Возвращаемся к списку загрузок
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Удалить'),
),
],
);
},
);
}
}

View File

@@ -1,163 +1,290 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:neomovies_mobile/utils/device_utils.dart';
import 'package:neomovies_mobile/presentation/widgets/player/web_player_widget.dart';
import 'package:neomovies_mobile/data/models/player/video_source.dart';
import 'package:video_player/video_player.dart';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
@RoutePage()
class VideoPlayerScreen extends StatefulWidget {
final String mediaId; // Теперь это IMDB ID
final String mediaType; // 'movie' or 'tv'
final String? title;
final String? subtitle;
final String? posterUrl;
final String filePath;
final String title;
const VideoPlayerScreen({
Key? key,
required this.mediaId,
required this.mediaType,
this.title,
this.subtitle,
this.posterUrl,
}) : super(key: key);
super.key,
required this.filePath,
required this.title,
});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
VideoSource _selectedSource = VideoSource.defaultSources.first;
VideoPlayerController? _controller;
bool _isControlsVisible = true;
bool _isFullscreen = false;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_setupPlayerEnvironment();
}
void _setupPlayerEnvironment() {
// Keep screen awake during video playback
WakelockPlus.enable();
// Set landscape orientation
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// Hide system UI for immersive experience
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_initializePlayer();
}
@override
void dispose() {
_restoreSystemSettings();
_controller?.dispose();
_setOrientation(false);
super.dispose();
}
void _restoreSystemSettings() {
// Restore system UI and allow screen to sleep
WakelockPlus.disable();
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
if (DeviceUtils.isLargeScreen(context)) {
Future<void> _initializePlayer() async {
try {
final file = File(widget.filePath);
if (!await file.exists()) {
setState(() {
_error = 'Файл не найден: ${widget.filePath}';
_isLoading = false;
});
return;
}
_controller = VideoPlayerController.file(file);
await _controller!.initialize();
_controller!.addListener(() {
setState(() {});
});
setState(() {
_isLoading = false;
});
// Auto play
_controller!.play();
} catch (e) {
setState(() {
_error = 'Ошибка инициализации плеера: $e';
_isLoading = false;
});
}
}
void _togglePlayPause() {
if (_controller!.value.isPlaying) {
_controller!.pause();
} else {
_controller!.play();
}
setState(() {});
}
void _toggleFullscreen() {
setState(() {
_isFullscreen = !_isFullscreen;
});
_setOrientation(_isFullscreen);
}
void _setOrientation(bool isFullscreen) {
if (isFullscreen) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
}
void _toggleControls() {
setState(() {
_isControlsVisible = !_isControlsVisible;
});
if (_isControlsVisible) {
// Hide controls after 3 seconds
Future.delayed(const Duration(seconds: 3), () {
if (mounted && _controller!.value.isPlaying) {
setState(() {
_isControlsVisible = false;
});
}
});
}
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final minutes = twoDigits(duration.inMinutes.remainder(60));
final seconds = twoDigits(duration.inSeconds.remainder(60));
final hours = duration.inHours;
// Restore system UI
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
if (hours > 0) {
return '$hours:$minutes:$seconds';
} else {
return '$minutes:$seconds';
}
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
_restoreSystemSettings();
return true;
},
child: _VideoPlayerScreenContent(
title: widget.title,
mediaId: widget.mediaId,
selectedSource: _selectedSource,
onSourceChanged: (source) {
if (mounted) {
setState(() {
_selectedSource = source;
});
}
},
),
);
}
}
class _VideoPlayerScreenContent extends StatelessWidget {
final String mediaId; // IMDB ID
final String? title;
final VideoSource selectedSource;
final ValueChanged<VideoSource> onSourceChanged;
const _VideoPlayerScreenContent({
Key? key,
required this.mediaId,
this.title,
required this.selectedSource,
required this.onSourceChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
appBar: _isFullscreen ? null : AppBar(
title: Text(
widget.title,
style: const TextStyle(color: Colors.white),
),
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
elevation: 0,
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Source selector header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.black87,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 8),
const Text(
'Источник: ',
style: TextStyle(color: Colors.white, fontSize: 16),
),
_buildSourceSelector(),
const Spacer(),
if (title != null)
Expanded(
flex: 2,
child: Text(
title!,
style: const TextStyle(color: Colors.white, fontSize: 14),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
],
const Icon(
Icons.error_outline,
size: 64,
color: Colors.white,
),
const SizedBox(height: 16),
const Text(
'Ошибка воспроизведения',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
// Video player
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Назад'),
),
],
),
);
}
if (_controller == null || !_controller!.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
}
return GestureDetector(
onTap: _toggleControls,
child: Stack(
children: [
// Video player
Center(
child: AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
),
),
// Controls overlay
if (_isControlsVisible)
_buildControlsOverlay(),
],
),
);
}
Widget _buildControlsOverlay() {
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),
],
stops: const [0.0, 0.3, 0.7, 1.0],
),
),
child: Column(
children: [
// Top bar
if (_isFullscreen) _buildTopBar(),
// Center play/pause
Expanded(
child: Center(
child: _buildCenterControls(),
),
),
// Bottom controls
_buildBottomControls(),
],
),
);
}
Widget _buildTopBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: WebPlayerWidget(
key: ValueKey(selectedSource.id),
mediaId: mediaId,
source: selectedSource,
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -166,24 +293,137 @@ class _VideoPlayerScreenContent extends StatelessWidget {
);
}
Widget _buildSourceSelector() {
return DropdownButton<VideoSource>(
value: selectedSource,
dropdownColor: Colors.black87,
style: const TextStyle(color: Colors.white),
underline: Container(),
items: VideoSource.defaultSources
.where((source) => source.isActive)
.map((source) => DropdownMenuItem<VideoSource>(
value: source,
child: Text(source.name),
))
.toList(),
onChanged: (VideoSource? newSource) {
if (newSource != null) {
onSourceChanged(newSource);
}
},
Widget _buildCenterControls() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 48,
icon: Icon(
Icons.replay_10,
color: Colors.white.withOpacity(0.8),
),
onPressed: () {
final newPosition = _controller!.value.position - const Duration(seconds: 10);
_controller!.seekTo(newPosition < Duration.zero ? Duration.zero : newPosition);
},
),
const SizedBox(width: 32),
Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: IconButton(
iconSize: 64,
icon: Icon(
_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: _togglePlayPause,
),
),
const SizedBox(width: 32),
IconButton(
iconSize: 48,
icon: Icon(
Icons.forward_10,
color: Colors.white.withOpacity(0.8),
),
onPressed: () {
final newPosition = _controller!.value.position + const Duration(seconds: 10);
final maxDuration = _controller!.value.duration;
_controller!.seekTo(newPosition > maxDuration ? maxDuration : newPosition);
},
),
],
);
}
}
Widget _buildBottomControls() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Progress bar
Row(
children: [
Text(
_formatDuration(_controller!.value.position),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
const SizedBox(width: 8),
Expanded(
child: VideoProgressIndicator(
_controller!,
allowScrubbing: true,
colors: VideoProgressColors(
playedColor: Theme.of(context).primaryColor,
backgroundColor: Colors.white.withOpacity(0.3),
bufferedColor: Colors.white.withOpacity(0.5),
),
),
),
const SizedBox(width: 8),
Text(
_formatDuration(_controller!.value.duration),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
const SizedBox(height: 16),
// Control buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: Icon(
_controller!.value.volume == 0 ? Icons.volume_off : Icons.volume_up,
color: Colors.white,
),
onPressed: () {
if (_controller!.value.volume == 0) {
_controller!.setVolume(1.0);
} else {
_controller!.setVolume(0.0);
}
setState(() {});
},
),
IconButton(
icon: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: _toggleFullscreen,
),
PopupMenuButton<double>(
icon: const Icon(Icons.speed, color: Colors.white),
onSelected: (speed) {
_controller!.setPlaybackSpeed(speed);
},
itemBuilder: (context) => [
const PopupMenuItem(value: 0.5, child: Text('0.5x')),
const PopupMenuItem(value: 0.75, child: Text('0.75x')),
const PopupMenuItem(value: 1.0, child: Text('1.0x')),
const PopupMenuItem(value: 1.25, child: Text('1.25x')),
const PopupMenuItem(value: 1.5, child: Text('1.5x')),
const PopupMenuItem(value: 2.0, child: Text('2.0x')),
],
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:auto_route/auto_route.dart';
enum WebPlayerType { vibix, alloha }
@RoutePage()
class WebViewPlayerScreen extends StatefulWidget {
final WebPlayerType playerType;
final String videoUrl;
final String title;
const WebViewPlayerScreen({
super.key,
required this.playerType,
required this.videoUrl,
required this.title,
});
@override
State<WebViewPlayerScreen> createState() => _WebViewPlayerScreenState();
}
class _WebViewPlayerScreenState extends State<WebViewPlayerScreen> {
late WebViewController _controller;
bool _isLoading = true;
bool _isFullscreen = false;
String? _error;
@override
void initState() {
super.initState();
_initializeWebView();
}
@override
void dispose() {
_setOrientation(false);
super.dispose();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
// Update loading progress
},
onPageStarted: (String url) {
setState(() {
_isLoading = true;
_error = null;
});
},
onPageFinished: (String url) {
setState(() {
_isLoading = false;
});
},
onWebResourceError: (WebResourceError error) {
setState(() {
_error = 'Ошибка загрузки: ${error.description}';
_isLoading = false;
});
},
),
);
_loadPlayer();
}
void _loadPlayer() {
final playerUrl = _getPlayerUrl();
_controller.loadRequest(Uri.parse(playerUrl));
}
String _getPlayerUrl() {
switch (widget.playerType) {
case WebPlayerType.vibix:
return _getVibixUrl();
case WebPlayerType.alloha:
return _getAllohaUrl();
}
}
String _getVibixUrl() {
// Vibix player URL with embedded video
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
}
String _getAllohaUrl() {
// Alloha player URL with embedded video
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
}
void _toggleFullscreen() {
setState(() {
_isFullscreen = !_isFullscreen;
});
_setOrientation(_isFullscreen);
}
void _setOrientation(bool isFullscreen) {
if (isFullscreen) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
}
String _getPlayerName() {
switch (widget.playerType) {
case WebPlayerType.vibix:
return 'Vibix';
case WebPlayerType.alloha:
return 'Alloha';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: _isFullscreen ? null : AppBar(
title: Text(
'${_getPlayerName()} - ${widget.title}',
style: const TextStyle(color: Colors.white),
),
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
elevation: 0,
actions: [
IconButton(
icon: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: _toggleFullscreen,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: (value) => _handleMenuAction(value),
itemBuilder: (BuildContext context) => [
const PopupMenuItem(
value: 'reload',
child: Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Перезагрузить'),
],
),
),
const PopupMenuItem(
value: 'share',
child: Row(
children: [
Icon(Icons.share),
SizedBox(width: 8),
Text('Поделиться'),
],
),
),
],
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_error != null) {
return _buildErrorState();
}
return Stack(
children: [
// WebView
WebViewWidget(controller: _controller),
// Loading indicator
if (_isLoading)
Container(
color: Colors.black,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Colors.white,
),
SizedBox(height: 16),
Text(
'Загрузка плеера...',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
],
),
),
),
// Fullscreen toggle for when player is loaded
if (!_isLoading && !_isFullscreen)
Positioned(
top: 16,
right: 16,
child: SafeArea(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
icon: const Icon(Icons.fullscreen, color: Colors.white),
onPressed: _toggleFullscreen,
),
),
),
),
],
);
}
Widget _buildErrorState() {
return Center(
child: Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade300,
),
const SizedBox(height: 16),
Text(
'Ошибка загрузки плеера',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
_error!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
setState(() {
_error = null;
});
_loadPlayer();
},
child: const Text('Повторить'),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
),
child: const Text('Назад'),
),
],
),
const SizedBox(height: 16),
_buildPlayerInfo(),
],
),
),
);
}
Widget _buildPlayerInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade900.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Информация о плеере',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_buildInfoRow('Плеер', _getPlayerName()),
_buildInfoRow('Файл', widget.title),
_buildInfoRow('URL', widget.videoUrl),
],
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Text(
'$label:',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
void _handleMenuAction(String action) {
switch (action) {
case 'reload':
_loadPlayer();
break;
case 'share':
_shareVideo();
break;
}
}
void _shareVideo() {
// TODO: Implement sharing functionality
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Поделиться: ${widget.title}'),
backgroundColor: Colors.green,
),
);
}
}
// Helper widget for creating custom HTML player if needed
class CustomPlayerWidget extends StatelessWidget {
final String videoUrl;
final String title;
final WebPlayerType playerType;
const CustomPlayerWidget({
super.key,
required this.videoUrl,
required this.title,
required this.playerType,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_filled,
size: 64,
color: Colors.white.withOpacity(0.8),
),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Плеер: ${playerType == WebPlayerType.vibix ? 'Vibix' : 'Alloha'}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
const SizedBox(height: 24),
const Text(
'Нажмите для воспроизведения',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
);
}
}