mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-27 19:58:50 +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.
598 lines
17 KiB
Dart
598 lines
17 KiB
Dart
import 'dart:convert';
|
||
import 'package:flutter/services.dart';
|
||
import '../models/torrent_info.dart';
|
||
|
||
/// Data classes for torrent metadata (matching Kotlin side)
|
||
|
||
/// Базовая информация из magnet-ссылки
|
||
class MagnetBasicInfo {
|
||
final String name;
|
||
final String infoHash;
|
||
final List<String> trackers;
|
||
final int totalSize;
|
||
|
||
MagnetBasicInfo({
|
||
required this.name,
|
||
required this.infoHash,
|
||
required this.trackers,
|
||
this.totalSize = 0,
|
||
});
|
||
|
||
factory MagnetBasicInfo.fromJson(Map<String, dynamic> json) {
|
||
return MagnetBasicInfo(
|
||
name: json['name'] as String,
|
||
infoHash: json['infoHash'] as String,
|
||
trackers: List<String>.from(json['trackers'] as List),
|
||
totalSize: json['totalSize'] as int? ?? 0,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return {
|
||
'name': name,
|
||
'infoHash': infoHash,
|
||
'trackers': trackers,
|
||
'totalSize': totalSize,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Информация о файле в торренте
|
||
class FileInfo {
|
||
final String name;
|
||
final String path;
|
||
final int size;
|
||
final int index;
|
||
final String extension;
|
||
final bool isVideo;
|
||
final bool isAudio;
|
||
final bool isImage;
|
||
final bool isDocument;
|
||
final bool selected;
|
||
|
||
FileInfo({
|
||
required this.name,
|
||
required this.path,
|
||
required this.size,
|
||
required this.index,
|
||
this.extension = '',
|
||
this.isVideo = false,
|
||
this.isAudio = false,
|
||
this.isImage = false,
|
||
this.isDocument = false,
|
||
this.selected = false,
|
||
});
|
||
|
||
factory FileInfo.fromJson(Map<String, dynamic> json) {
|
||
return FileInfo(
|
||
name: json['name'] as String,
|
||
path: json['path'] as String,
|
||
size: json['size'] as int,
|
||
index: json['index'] as int,
|
||
extension: json['extension'] as String? ?? '',
|
||
isVideo: json['isVideo'] as bool? ?? false,
|
||
isAudio: json['isAudio'] as bool? ?? false,
|
||
isImage: json['isImage'] as bool? ?? false,
|
||
isDocument: json['isDocument'] as bool? ?? false,
|
||
selected: json['selected'] as bool? ?? false,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return {
|
||
'name': name,
|
||
'path': path,
|
||
'size': size,
|
||
'index': index,
|
||
'extension': extension,
|
||
'isVideo': isVideo,
|
||
'isAudio': isAudio,
|
||
'isImage': isImage,
|
||
'isDocument': isDocument,
|
||
'selected': selected,
|
||
};
|
||
}
|
||
|
||
FileInfo copyWith({
|
||
String? name,
|
||
String? path,
|
||
int? size,
|
||
int? index,
|
||
String? extension,
|
||
bool? isVideo,
|
||
bool? isAudio,
|
||
bool? isImage,
|
||
bool? isDocument,
|
||
bool? selected,
|
||
}) {
|
||
return FileInfo(
|
||
name: name ?? this.name,
|
||
path: path ?? this.path,
|
||
size: size ?? this.size,
|
||
index: index ?? this.index,
|
||
extension: extension ?? this.extension,
|
||
isVideo: isVideo ?? this.isVideo,
|
||
isAudio: isAudio ?? this.isAudio,
|
||
isImage: isImage ?? this.isImage,
|
||
isDocument: isDocument ?? this.isDocument,
|
||
selected: selected ?? this.selected,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Узел директории
|
||
class DirectoryNode {
|
||
final String name;
|
||
final String path;
|
||
final List<FileInfo> files;
|
||
final List<DirectoryNode> subdirectories;
|
||
final int totalSize;
|
||
final int fileCount;
|
||
|
||
DirectoryNode({
|
||
required this.name,
|
||
required this.path,
|
||
required this.files,
|
||
required this.subdirectories,
|
||
required this.totalSize,
|
||
required this.fileCount,
|
||
});
|
||
|
||
factory DirectoryNode.fromJson(Map<String, dynamic> json) {
|
||
return DirectoryNode(
|
||
name: json['name'] as String,
|
||
path: json['path'] as String,
|
||
files: (json['files'] as List)
|
||
.map((file) => FileInfo.fromJson(file as Map<String, dynamic>))
|
||
.toList(),
|
||
subdirectories: (json['subdirectories'] as List)
|
||
.map((dir) => DirectoryNode.fromJson(dir as Map<String, dynamic>))
|
||
.toList(),
|
||
totalSize: json['totalSize'] as int,
|
||
fileCount: json['fileCount'] as int,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Структура файлов торрента
|
||
class FileStructure {
|
||
final DirectoryNode rootDirectory;
|
||
final int totalFiles;
|
||
final Map<String, int> filesByType;
|
||
|
||
FileStructure({
|
||
required this.rootDirectory,
|
||
required this.totalFiles,
|
||
required this.filesByType,
|
||
});
|
||
|
||
factory FileStructure.fromJson(Map<String, dynamic> json) {
|
||
return FileStructure(
|
||
rootDirectory: DirectoryNode.fromJson(json['rootDirectory'] as Map<String, dynamic>),
|
||
totalFiles: json['totalFiles'] as int,
|
||
filesByType: Map<String, int>.from(json['filesByType'] as Map),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Полные метаданные торрента
|
||
class TorrentMetadataFull {
|
||
final String name;
|
||
final String infoHash;
|
||
final int totalSize;
|
||
final int pieceLength;
|
||
final int numPieces;
|
||
final FileStructure fileStructure;
|
||
final List<String> trackers;
|
||
final int creationDate;
|
||
final String comment;
|
||
final String createdBy;
|
||
|
||
TorrentMetadataFull({
|
||
required this.name,
|
||
required this.infoHash,
|
||
required this.totalSize,
|
||
required this.pieceLength,
|
||
required this.numPieces,
|
||
required this.fileStructure,
|
||
required this.trackers,
|
||
required this.creationDate,
|
||
required this.comment,
|
||
required this.createdBy,
|
||
});
|
||
|
||
factory TorrentMetadataFull.fromJson(Map<String, dynamic> json) {
|
||
return TorrentMetadataFull(
|
||
name: json['name'] as String,
|
||
infoHash: json['infoHash'] as String,
|
||
totalSize: json['totalSize'] as int,
|
||
pieceLength: json['pieceLength'] as int,
|
||
numPieces: json['numPieces'] as int,
|
||
fileStructure: FileStructure.fromJson(json['fileStructure'] as Map<String, dynamic>),
|
||
trackers: List<String>.from(json['trackers'] as List),
|
||
creationDate: json['creationDate'] as int,
|
||
comment: json['comment'] as String,
|
||
createdBy: json['createdBy'] as String,
|
||
);
|
||
}
|
||
|
||
/// Получить плоский список всех файлов
|
||
List<FileInfo> getAllFiles() {
|
||
final List<FileInfo> allFiles = [];
|
||
_collectFiles(fileStructure.rootDirectory, allFiles);
|
||
return allFiles;
|
||
}
|
||
|
||
void _collectFiles(DirectoryNode directory, List<FileInfo> result) {
|
||
result.addAll(directory.files);
|
||
for (final subdir in directory.subdirectories) {
|
||
_collectFiles(subdir, result);
|
||
}
|
||
}
|
||
}
|
||
|
||
class TorrentFileInfo {
|
||
final String path;
|
||
final int size;
|
||
final bool selected;
|
||
|
||
TorrentFileInfo({
|
||
required this.path,
|
||
required this.size,
|
||
this.selected = false,
|
||
});
|
||
|
||
factory TorrentFileInfo.fromJson(Map<String, dynamic> json) {
|
||
return TorrentFileInfo(
|
||
path: json['path'] as String,
|
||
size: json['size'] as int,
|
||
selected: json['selected'] as bool? ?? false,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return {
|
||
'path': path,
|
||
'size': size,
|
||
'selected': selected,
|
||
};
|
||
}
|
||
|
||
TorrentFileInfo copyWith({
|
||
String? path,
|
||
int? size,
|
||
bool? selected,
|
||
}) {
|
||
return TorrentFileInfo(
|
||
path: path ?? this.path,
|
||
size: size ?? this.size,
|
||
selected: selected ?? this.selected,
|
||
);
|
||
}
|
||
}
|
||
|
||
class TorrentMetadata {
|
||
final String name;
|
||
final int totalSize;
|
||
final List<TorrentFileInfo> files;
|
||
final String infoHash;
|
||
|
||
TorrentMetadata({
|
||
required this.name,
|
||
required this.totalSize,
|
||
required this.files,
|
||
required this.infoHash,
|
||
});
|
||
|
||
factory TorrentMetadata.fromJson(Map<String, dynamic> json) {
|
||
return TorrentMetadata(
|
||
name: json['name'] as String,
|
||
totalSize: json['totalSize'] as int,
|
||
files: (json['files'] as List)
|
||
.map((file) => TorrentFileInfo.fromJson(file as Map<String, dynamic>))
|
||
.toList(),
|
||
infoHash: json['infoHash'] as String,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return {
|
||
'name': name,
|
||
'totalSize': totalSize,
|
||
'files': files.map((file) => file.toJson()).toList(),
|
||
'infoHash': infoHash,
|
||
};
|
||
}
|
||
}
|
||
|
||
class DownloadProgress {
|
||
final String infoHash;
|
||
final double progress;
|
||
final int downloadRate;
|
||
final int uploadRate;
|
||
final int numSeeds;
|
||
final int numPeers;
|
||
final String state;
|
||
|
||
DownloadProgress({
|
||
required this.infoHash,
|
||
required this.progress,
|
||
required this.downloadRate,
|
||
required this.uploadRate,
|
||
required this.numSeeds,
|
||
required this.numPeers,
|
||
required this.state,
|
||
});
|
||
|
||
factory DownloadProgress.fromJson(Map<String, dynamic> json) {
|
||
return DownloadProgress(
|
||
infoHash: json['infoHash'] as String,
|
||
progress: (json['progress'] as num).toDouble(),
|
||
downloadRate: json['downloadRate'] as int,
|
||
uploadRate: json['uploadRate'] as int,
|
||
numSeeds: json['numSeeds'] as int,
|
||
numPeers: json['numPeers'] as int,
|
||
state: json['state'] as String,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Platform service for torrent operations using jlibtorrent on Android
|
||
class TorrentPlatformService {
|
||
static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent');
|
||
|
||
/// Add torrent from magnet URI and start downloading
|
||
static Future<String> addTorrent({
|
||
required String magnetUri,
|
||
String? savePath,
|
||
}) async {
|
||
try {
|
||
final String infoHash = await _channel.invokeMethod('addTorrent', {
|
||
'magnetUri': magnetUri,
|
||
'savePath': savePath ?? '/storage/emulated/0/Download/NeoMovies',
|
||
});
|
||
|
||
return infoHash;
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to add torrent: ${e.message}');
|
||
}
|
||
}
|
||
|
||
/// Get all torrents
|
||
static Future<List<DownloadProgress>> getAllDownloads() async {
|
||
try {
|
||
final String result = await _channel.invokeMethod('getTorrents');
|
||
|
||
final List<dynamic> jsonList = jsonDecode(result);
|
||
return jsonList.map((json) {
|
||
final data = json as Map<String, dynamic>;
|
||
return DownloadProgress(
|
||
infoHash: data['infoHash'] as String,
|
||
progress: (data['progress'] as num).toDouble(),
|
||
downloadRate: data['downloadSpeed'] as int,
|
||
uploadRate: data['uploadSpeed'] as int,
|
||
numSeeds: data['numSeeds'] as int,
|
||
numPeers: data['numPeers'] as int,
|
||
state: data['state'] as String,
|
||
);
|
||
}).toList();
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to get all downloads: ${e.message}');
|
||
} catch (e) {
|
||
throw Exception('Failed to parse downloads: $e');
|
||
}
|
||
}
|
||
|
||
/// Get single torrent info
|
||
static Future<TorrentInfo?> getTorrent(String infoHash) async {
|
||
try {
|
||
final String result = await _channel.invokeMethod('getTorrent', {
|
||
'infoHash': infoHash,
|
||
});
|
||
|
||
final Map<String, dynamic> json = jsonDecode(result);
|
||
return TorrentInfo.fromAndroidJson(json);
|
||
} on PlatformException catch (e) {
|
||
if (e.code == 'NOT_FOUND') return null;
|
||
throw Exception('Failed to get torrent: ${e.message}');
|
||
} catch (e) {
|
||
throw Exception('Failed to parse torrent: $e');
|
||
}
|
||
}
|
||
|
||
/// Get download progress for a torrent
|
||
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
|
||
try {
|
||
final torrentInfo = await getTorrent(infoHash);
|
||
if (torrentInfo == null) return null;
|
||
|
||
return DownloadProgress(
|
||
infoHash: torrentInfo.infoHash,
|
||
progress: torrentInfo.progress,
|
||
downloadRate: torrentInfo.downloadSpeed,
|
||
uploadRate: torrentInfo.uploadSpeed,
|
||
numSeeds: torrentInfo.numSeeds,
|
||
numPeers: torrentInfo.numPeers,
|
||
state: torrentInfo.state,
|
||
);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Pause download
|
||
static Future<bool> pauseDownload(String infoHash) async {
|
||
try {
|
||
final bool result = await _channel.invokeMethod('pauseTorrent', {
|
||
'infoHash': infoHash,
|
||
});
|
||
|
||
return result;
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to pause download: ${e.message}');
|
||
}
|
||
}
|
||
|
||
/// Resume download
|
||
static Future<bool> resumeDownload(String infoHash) async {
|
||
try {
|
||
final bool result = await _channel.invokeMethod('resumeTorrent', {
|
||
'infoHash': infoHash,
|
||
});
|
||
|
||
return result;
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to resume download: ${e.message}');
|
||
}
|
||
}
|
||
|
||
/// Cancel and remove download
|
||
static Future<bool> cancelDownload(String infoHash) async {
|
||
try {
|
||
final bool result = await _channel.invokeMethod('removeTorrent', {
|
||
'infoHash': infoHash,
|
||
'deleteFiles': true,
|
||
});
|
||
|
||
return result;
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to cancel download: ${e.message}');
|
||
}
|
||
}
|
||
|
||
/// Set file priority
|
||
static Future<bool> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||
try {
|
||
final bool result = await _channel.invokeMethod('setFilePriority', {
|
||
'infoHash': infoHash,
|
||
'fileIndex': fileIndex,
|
||
'priority': priority.value,
|
||
});
|
||
|
||
return result;
|
||
} on PlatformException catch (e) {
|
||
throw Exception('Failed to set file priority: ${e.message}');
|
||
}
|
||
}
|
||
|
||
/// Start downloading selected files from torrent
|
||
static Future<String> startDownload({
|
||
required String magnetLink,
|
||
required List<int> selectedFiles,
|
||
String? downloadPath,
|
||
}) async {
|
||
try {
|
||
// First add the torrent
|
||
final String infoHash = await addTorrent(
|
||
magnetUri: magnetLink,
|
||
savePath: downloadPath,
|
||
);
|
||
|
||
// Wait for metadata to be received
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
|
||
// Set file priorities
|
||
final torrentInfo = await getTorrent(infoHash);
|
||
if (torrentInfo != null) {
|
||
for (int i = 0; i < torrentInfo.files.length; i++) {
|
||
final priority = selectedFiles.contains(i)
|
||
? FilePriority.NORMAL
|
||
: FilePriority.DONT_DOWNLOAD;
|
||
await setFilePriority(infoHash, i, priority);
|
||
}
|
||
}
|
||
|
||
return infoHash;
|
||
} catch (e) {
|
||
throw Exception('Failed to start download: $e');
|
||
}
|
||
}
|
||
|
||
// Legacy methods for compatibility with existing code
|
||
|
||
/// Get torrent metadata from magnet link (legacy method)
|
||
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
|
||
try {
|
||
// This is a simplified implementation that adds the torrent and gets metadata
|
||
final infoHash = await addTorrent(magnetUri: magnetLink);
|
||
await Future.delayed(const Duration(seconds: 3)); // Wait for metadata
|
||
|
||
final torrentInfo = await getTorrent(infoHash);
|
||
if (torrentInfo == null) {
|
||
throw Exception('Failed to get torrent metadata');
|
||
}
|
||
|
||
return TorrentMetadata(
|
||
name: torrentInfo.name,
|
||
totalSize: torrentInfo.totalSize,
|
||
files: torrentInfo.files.map((file) => TorrentFileInfo(
|
||
path: file.path,
|
||
size: file.size,
|
||
selected: file.priority > FilePriority.DONT_DOWNLOAD,
|
||
)).toList(),
|
||
infoHash: torrentInfo.infoHash,
|
||
);
|
||
} catch (e) {
|
||
throw Exception('Failed to get torrent metadata: $e');
|
||
}
|
||
}
|
||
|
||
/// Получить базовую информацию из magnet-ссылки (legacy)
|
||
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
||
try {
|
||
// Parse magnet URI manually since Android implementation doesn't have this
|
||
final uri = Uri.parse(magnetUri);
|
||
final params = uri.queryParameters;
|
||
|
||
return MagnetBasicInfo(
|
||
name: params['dn'] ?? 'Unknown',
|
||
infoHash: params['xt']?.replaceFirst('urn:btih:', '') ?? '',
|
||
trackers: params['tr'] != null ? [params['tr']!] : [],
|
||
totalSize: 0,
|
||
);
|
||
} catch (e) {
|
||
throw Exception('Failed to parse magnet basic info: $e');
|
||
}
|
||
}
|
||
|
||
/// Получить полные метаданные торрента (legacy)
|
||
static Future<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
|
||
try {
|
||
final basicInfo = await parseMagnetBasicInfo(magnetUri);
|
||
final metadata = await getTorrentMetadata(magnetUri);
|
||
|
||
return TorrentMetadataFull(
|
||
name: metadata.name,
|
||
infoHash: metadata.infoHash,
|
||
totalSize: metadata.totalSize,
|
||
pieceLength: 0,
|
||
numPieces: 0,
|
||
fileStructure: FileStructure(
|
||
rootDirectory: DirectoryNode(
|
||
name: metadata.name,
|
||
path: '/',
|
||
files: metadata.files.map((file) => FileInfo(
|
||
name: file.path.split('/').last,
|
||
path: file.path,
|
||
size: file.size,
|
||
index: metadata.files.indexOf(file),
|
||
)).toList(),
|
||
subdirectories: [],
|
||
totalSize: metadata.totalSize,
|
||
fileCount: metadata.files.length,
|
||
),
|
||
totalFiles: metadata.files.length,
|
||
filesByType: {'video': metadata.files.length},
|
||
),
|
||
trackers: basicInfo.trackers,
|
||
creationDate: 0,
|
||
comment: '',
|
||
createdBy: '',
|
||
);
|
||
} catch (e) {
|
||
throw Exception('Failed to fetch full metadata: $e');
|
||
}
|
||
}
|
||
}
|
||
}
|