mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 04:38:49 +05:00
- 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.
535 lines
16 KiB
Dart
535 lines
16 KiB
Dart
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,
|
|
});
|
|
} |