torrent downloads

This commit is contained in:
2025-07-19 20:50:26 +03:00
parent 4ea75db105
commit de26fd3fc9
10 changed files with 1303 additions and 38 deletions

View File

@@ -0,0 +1,128 @@
package com.example.neomovies_mobile
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import com.google.gson.Gson
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.neo.neomovies/torrent"
private lateinit var torrentService: TorrentService
private val gson = Gson()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Initialize torrent service
torrentService = TorrentService(this)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getTorrentMetadata" -> {
val magnetLink = call.argument<String>("magnetLink")
if (magnetLink != null) {
CoroutineScope(Dispatchers.Main).launch {
try {
val metadata = torrentService.getTorrentMetadata(magnetLink)
if (metadata.isSuccess) {
result.success(gson.toJson(metadata.getOrNull()))
} else {
result.error("METADATA_ERROR", metadata.exceptionOrNull()?.message, null)
}
} catch (e: Exception) {
result.error("METADATA_ERROR", e.message, null)
}
}
} else {
result.error("INVALID_ARGUMENT", "magnetLink is required", null)
}
}
"startDownload" -> {
val magnetLink = call.argument<String>("magnetLink")
val selectedFiles = call.argument<List<Int>>("selectedFiles")
val downloadPath = call.argument<String>("downloadPath")
if (magnetLink != null && selectedFiles != null) {
CoroutineScope(Dispatchers.Main).launch {
try {
val downloadResult = torrentService.startDownload(magnetLink, selectedFiles, downloadPath)
if (downloadResult.isSuccess) {
result.success(downloadResult.getOrNull())
} else {
result.error("DOWNLOAD_ERROR", downloadResult.exceptionOrNull()?.message, null)
}
} catch (e: Exception) {
result.error("DOWNLOAD_ERROR", e.message, null)
}
}
} else {
result.error("INVALID_ARGUMENT", "magnetLink and selectedFiles are required", null)
}
}
"getDownloadProgress" -> {
val infoHash = call.argument<String>("infoHash")
if (infoHash != null) {
val progress = torrentService.getDownloadProgress(infoHash)
if (progress != null) {
result.success(gson.toJson(progress))
} else {
result.error("NOT_FOUND", "Download not found", null)
}
} else {
result.error("INVALID_ARGUMENT", "infoHash is required", null)
}
}
"pauseDownload" -> {
val infoHash = call.argument<String>("infoHash")
if (infoHash != null) {
val success = torrentService.pauseDownload(infoHash)
result.success(success)
} else {
result.error("INVALID_ARGUMENT", "infoHash is required", null)
}
}
"resumeDownload" -> {
val infoHash = call.argument<String>("infoHash")
if (infoHash != null) {
val success = torrentService.resumeDownload(infoHash)
result.success(success)
} else {
result.error("INVALID_ARGUMENT", "infoHash is required", null)
}
}
"cancelDownload" -> {
val infoHash = call.argument<String>("infoHash")
if (infoHash != null) {
val success = torrentService.cancelDownload(infoHash)
result.success(success)
} else {
result.error("INVALID_ARGUMENT", "infoHash is required", null)
}
}
"getAllDownloads" -> {
val downloads = torrentService.getAllDownloads()
result.success(gson.toJson(downloads))
}
else -> {
result.notImplemented()
}
}
}
}
override fun onDestroy() {
super.onDestroy()
if (::torrentService.isInitialized) {
torrentService.cleanup()
}
}
}

View File

@@ -0,0 +1,275 @@
package com.example.neomovies_mobile
import android.content.Context
import android.os.Environment
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.*
import org.libtorrent4j.*
import org.libtorrent4j.alerts.*
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Data classes for torrent metadata
*/
data class TorrentFileInfo(
@SerializedName("path") val path: String,
@SerializedName("size") val size: Long,
@SerializedName("selected") val selected: Boolean = false
)
data class TorrentMetadata(
@SerializedName("name") val name: String,
@SerializedName("totalSize") val totalSize: Long,
@SerializedName("files") val files: List<TorrentFileInfo>,
@SerializedName("infoHash") val infoHash: String
)
data class DownloadProgress(
@SerializedName("infoHash") val infoHash: String,
@SerializedName("progress") val progress: Float,
@SerializedName("downloadRate") val downloadRate: Long,
@SerializedName("uploadRate") val uploadRate: Long,
@SerializedName("numSeeds") val numSeeds: Int,
@SerializedName("numPeers") val numPeers: Int,
@SerializedName("state") val state: String
)
/**
* Torrent service using jlibtorrent for metadata extraction and downloading
*/
class TorrentService(private val context: Context) {
private val gson = Gson()
private var sessionManager: SessionManager? = null
private val activeDownloads = mutableMapOf<String, TorrentHandle>()
companion object {
private const val METADATA_TIMEOUT_SECONDS = 30L
}
init {
initializeSession()
}
private fun initializeSession() {
try {
sessionManager = SessionManager().apply {
start()
// Configure session settings for metadata-only downloads
val settings = SettingsPacket().apply {
setString(settings_pack.string_types.user_agent.swigValue(), "NeoMovies/1.0")
setInt(settings_pack.int_types.alert_mask.swigValue(),
AlertType.ERROR.swig() or
AlertType.STORAGE.swig() or
AlertType.STATUS.swig() or
AlertType.TORRENT.swig())
}
applySettings(settings)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Get torrent metadata from magnet link
*/
suspend fun getTorrentMetadata(magnetLink: String): Result<TorrentMetadata> = withContext(Dispatchers.IO) {
try {
val session = sessionManager ?: return@withContext Result.failure(Exception("Session not initialized"))
// Parse magnet link
val params = SessionParams()
val addTorrentParams = AddTorrentParams.parseMagnetUri(magnetLink, params)
if (addTorrentParams == null) {
return@withContext Result.failure(Exception("Invalid magnet link"))
}
// Set flags for metadata-only download
addTorrentParams.flags = addTorrentParams.flags or TorrentFlags.UPLOAD_MODE.swig()
// Add torrent to session
val handle = session.addTorrent(addTorrentParams)
val infoHash = handle.infoHash().toString()
// Wait for metadata
val latch = CountDownLatch(1)
var metadata: TorrentMetadata? = null
var error: Exception? = null
val job = CoroutineScope(Dispatchers.IO).launch {
try {
// Wait for metadata with timeout
val startTime = System.currentTimeMillis()
while (!handle.status().hasMetadata() &&
System.currentTimeMillis() - startTime < METADATA_TIMEOUT_SECONDS * 1000) {
delay(100)
}
if (handle.status().hasMetadata()) {
val torrentInfo = handle.torrentFile()
val files = mutableListOf<TorrentFileInfo>()
for (i in 0 until torrentInfo.numFiles()) {
val fileEntry = torrentInfo.fileAt(i)
files.add(TorrentFileInfo(
path = fileEntry.path(),
size = fileEntry.size(),
selected = false
))
}
metadata = TorrentMetadata(
name = torrentInfo.name(),
totalSize = torrentInfo.totalSize(),
files = files,
infoHash = infoHash
)
} else {
error = Exception("Metadata timeout")
}
} catch (e: Exception) {
error = e
} finally {
// Remove torrent from session (metadata only)
session.removeTorrent(handle)
latch.countDown()
}
}
// Wait for completion
latch.await(METADATA_TIMEOUT_SECONDS + 5, TimeUnit.SECONDS)
job.cancel()
metadata?.let {
Result.success(it)
} ?: Result.failure(error ?: Exception("Unknown error"))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Start downloading selected files from torrent
*/
suspend fun startDownload(
magnetLink: String,
selectedFiles: List<Int>,
downloadPath: String? = null
): Result<String> = withContext(Dispatchers.IO) {
try {
val session = sessionManager ?: return@withContext Result.failure(Exception("Session not initialized"))
// Parse magnet link
val params = SessionParams()
val addTorrentParams = AddTorrentParams.parseMagnetUri(magnetLink, params)
if (addTorrentParams == null) {
return@withContext Result.failure(Exception("Invalid magnet link"))
}
// Set download path
val savePath = downloadPath ?: getDefaultDownloadPath()
addTorrentParams.savePath = savePath
// Add torrent to session
val handle = session.addTorrent(addTorrentParams)
val infoHash = handle.infoHash().toString()
// Wait for metadata first
while (!handle.status().hasMetadata()) {
delay(100)
}
// Set file priorities (only download selected files)
val torrentInfo = handle.torrentFile()
val priorities = IntArray(torrentInfo.numFiles()) { 0 } // 0 = don't download
selectedFiles.forEach { fileIndex ->
if (fileIndex < priorities.size) {
priorities[fileIndex] = 1 // 1 = normal priority
}
}
handle.prioritizeFiles(priorities)
handle.resume() // Start downloading
// Store active download
activeDownloads[infoHash] = handle
Result.success(infoHash)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Get download progress for a torrent
*/
fun getDownloadProgress(infoHash: String): DownloadProgress? {
val handle = activeDownloads[infoHash] ?: return null
val status = handle.status()
return DownloadProgress(
infoHash = infoHash,
progress = status.progress(),
downloadRate = status.downloadRate().toLong(),
uploadRate = status.uploadRate().toLong(),
numSeeds = status.numSeeds(),
numPeers = status.numPeers(),
state = status.state().name
)
}
/**
* Pause download
*/
fun pauseDownload(infoHash: String): Boolean {
val handle = activeDownloads[infoHash] ?: return false
handle.pause()
return true
}
/**
* Resume download
*/
fun resumeDownload(infoHash: String): Boolean {
val handle = activeDownloads[infoHash] ?: return false
handle.resume()
return true
}
/**
* Cancel and remove download
*/
fun cancelDownload(infoHash: String): Boolean {
val handle = activeDownloads[infoHash] ?: return false
sessionManager?.removeTorrent(handle)
activeDownloads.remove(infoHash)
return true
}
/**
* Get all active downloads
*/
fun getAllDownloads(): List<DownloadProgress> {
return activeDownloads.map { (infoHash, _) ->
getDownloadProgress(infoHash)
}.filterNotNull()
}
private fun getDefaultDownloadPath(): String {
return File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "NeoMovies").absolutePath
}
fun cleanup() {
activeDownloads.clear()
sessionManager?.stop()
sessionManager = null
}
}

View File

@@ -11,8 +11,10 @@ class Torrent with _$Torrent {
String? name, String? name,
String? quality, String? quality,
int? seeders, int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb, int? size, // размер в байтах
}) = _Torrent; }) = _Torrent;
factory Torrent.fromJson(Map<String, dynamic> json) => _$TorrentFromJson(json); factory Torrent.fromJson(Map<String, dynamic> json) => _$TorrentFromJson(json);
} }

View File

@@ -25,8 +25,7 @@ mixin _$Torrent {
String? get name => throw _privateConstructorUsedError; String? get name => throw _privateConstructorUsedError;
String? get quality => throw _privateConstructorUsedError; String? get quality => throw _privateConstructorUsedError;
int? get seeders => throw _privateConstructorUsedError; int? get seeders => throw _privateConstructorUsedError;
@JsonKey(name: 'size_gb') int? get size => throw _privateConstructorUsedError;
double? get sizeGb => throw _privateConstructorUsedError;
/// Serializes this Torrent to a JSON map. /// Serializes this Torrent to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -48,7 +47,7 @@ abstract class $TorrentCopyWith<$Res> {
String? name, String? name,
String? quality, String? quality,
int? seeders, int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb}); int? size});
} }
/// @nodoc /// @nodoc
@@ -71,7 +70,7 @@ class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
Object? name = freezed, Object? name = freezed,
Object? quality = freezed, Object? quality = freezed,
Object? seeders = freezed, Object? seeders = freezed,
Object? sizeGb = freezed, Object? size = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
magnet: null == magnet magnet: null == magnet
@@ -94,10 +93,10 @@ class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
? _value.seeders ? _value.seeders
: seeders // ignore: cast_nullable_to_non_nullable : seeders // ignore: cast_nullable_to_non_nullable
as int?, as int?,
sizeGb: freezed == sizeGb size: freezed == size
? _value.sizeGb ? _value.size
: sizeGb // ignore: cast_nullable_to_non_nullable : size // ignore: cast_nullable_to_non_nullable
as double?, as int?,
) as $Val); ) as $Val);
} }
} }
@@ -115,7 +114,7 @@ abstract class _$$TorrentImplCopyWith<$Res> implements $TorrentCopyWith<$Res> {
String? name, String? name,
String? quality, String? quality,
int? seeders, int? seeders,
@JsonKey(name: 'size_gb') double? sizeGb}); int? size});
} }
/// @nodoc /// @nodoc
@@ -136,7 +135,7 @@ class __$$TorrentImplCopyWithImpl<$Res>
Object? name = freezed, Object? name = freezed,
Object? quality = freezed, Object? quality = freezed,
Object? seeders = freezed, Object? seeders = freezed,
Object? sizeGb = freezed, Object? size = freezed,
}) { }) {
return _then(_$TorrentImpl( return _then(_$TorrentImpl(
magnet: null == magnet magnet: null == magnet
@@ -159,10 +158,10 @@ class __$$TorrentImplCopyWithImpl<$Res>
? _value.seeders ? _value.seeders
: seeders // ignore: cast_nullable_to_non_nullable : seeders // ignore: cast_nullable_to_non_nullable
as int?, as int?,
sizeGb: freezed == sizeGb size: freezed == size
? _value.sizeGb ? _value.size
: sizeGb // ignore: cast_nullable_to_non_nullable : size // ignore: cast_nullable_to_non_nullable
as double?, as int?,
)); ));
} }
} }
@@ -176,7 +175,7 @@ class _$TorrentImpl implements _Torrent {
this.name, this.name,
this.quality, this.quality,
this.seeders, this.seeders,
@JsonKey(name: 'size_gb') this.sizeGb}); this.size});
factory _$TorrentImpl.fromJson(Map<String, dynamic> json) => factory _$TorrentImpl.fromJson(Map<String, dynamic> json) =>
_$$TorrentImplFromJson(json); _$$TorrentImplFromJson(json);
@@ -192,12 +191,11 @@ class _$TorrentImpl implements _Torrent {
@override @override
final int? seeders; final int? seeders;
@override @override
@JsonKey(name: 'size_gb') final int? size;
final double? sizeGb;
@override @override
String toString() { String toString() {
return 'Torrent(magnet: $magnet, title: $title, name: $name, quality: $quality, seeders: $seeders, sizeGb: $sizeGb)'; return 'Torrent(magnet: $magnet, title: $title, name: $name, quality: $quality, seeders: $seeders, size: $size)';
} }
@override @override
@@ -210,13 +208,13 @@ class _$TorrentImpl implements _Torrent {
(identical(other.name, name) || other.name == name) && (identical(other.name, name) || other.name == name) &&
(identical(other.quality, quality) || other.quality == quality) && (identical(other.quality, quality) || other.quality == quality) &&
(identical(other.seeders, seeders) || other.seeders == seeders) && (identical(other.seeders, seeders) || other.seeders == seeders) &&
(identical(other.sizeGb, sizeGb) || other.sizeGb == sizeGb)); (identical(other.size, size) || other.size == size));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => int get hashCode =>
Object.hash(runtimeType, magnet, title, name, quality, seeders, sizeGb); Object.hash(runtimeType, magnet, title, name, quality, seeders, size);
/// Create a copy of Torrent /// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -241,7 +239,7 @@ abstract class _Torrent implements Torrent {
final String? name, final String? name,
final String? quality, final String? quality,
final int? seeders, final int? seeders,
@JsonKey(name: 'size_gb') final double? sizeGb}) = _$TorrentImpl; final int? size}) = _$TorrentImpl;
factory _Torrent.fromJson(Map<String, dynamic> json) = _$TorrentImpl.fromJson; factory _Torrent.fromJson(Map<String, dynamic> json) = _$TorrentImpl.fromJson;
@@ -256,8 +254,7 @@ abstract class _Torrent implements Torrent {
@override @override
int? get seeders; int? get seeders;
@override @override
@JsonKey(name: 'size_gb') int? get size;
double? get sizeGb;
/// Create a copy of Torrent /// Create a copy of Torrent
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View File

@@ -13,7 +13,7 @@ _$TorrentImpl _$$TorrentImplFromJson(Map<String, dynamic> json) =>
name: json['name'] as String?, name: json['name'] as String?,
quality: json['quality'] as String?, quality: json['quality'] as String?,
seeders: (json['seeders'] as num?)?.toInt(), seeders: (json['seeders'] as num?)?.toInt(),
sizeGb: (json['size_gb'] as num?)?.toDouble(), size: (json['size'] as num?)?.toInt(),
); );
Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) => Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) =>
@@ -23,5 +23,5 @@ Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) =>
'name': instance.name, 'name': instance.name,
'quality': instance.quality, 'quality': instance.quality,
'seeders': instance.seeders, 'seeders': instance.seeders,
'size_gb': instance.sizeGb, 'size': instance.size,
}; };

View File

@@ -0,0 +1,223 @@
import 'dart:convert';
import 'package:flutter/services.dart';
/// Data classes for torrent metadata (matching Kotlin side)
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/torrent');
/// Get torrent metadata from magnet link
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
try {
final String result = await _channel.invokeMethod('getTorrentMetadata', {
'magnetLink': magnetLink,
});
final Map<String, dynamic> json = jsonDecode(result);
return TorrentMetadata.fromJson(json);
} on PlatformException catch (e) {
throw Exception('Failed to get torrent metadata: ${e.message}');
} catch (e) {
throw Exception('Failed to parse torrent metadata: $e');
}
}
/// Start downloading selected files from torrent
static Future<String> startDownload({
required String magnetLink,
required List<int> selectedFiles,
String? downloadPath,
}) async {
try {
final String infoHash = await _channel.invokeMethod('startDownload', {
'magnetLink': magnetLink,
'selectedFiles': selectedFiles,
'downloadPath': downloadPath,
});
return infoHash;
} on PlatformException catch (e) {
throw Exception('Failed to start download: ${e.message}');
}
}
/// Get download progress for a torrent
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
try {
final String? result = await _channel.invokeMethod('getDownloadProgress', {
'infoHash': infoHash,
});
if (result == null) return null;
final Map<String, dynamic> json = jsonDecode(result);
return DownloadProgress.fromJson(json);
} on PlatformException catch (e) {
if (e.code == 'NOT_FOUND') return null;
throw Exception('Failed to get download progress: ${e.message}');
} catch (e) {
throw Exception('Failed to parse download progress: $e');
}
}
/// Pause download
static Future<bool> pauseDownload(String infoHash) async {
try {
final bool result = await _channel.invokeMethod('pauseDownload', {
'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('resumeDownload', {
'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('cancelDownload', {
'infoHash': infoHash,
});
return result;
} on PlatformException catch (e) {
throw Exception('Failed to cancel download: ${e.message}');
}
}
/// Get all active downloads
static Future<List<DownloadProgress>> getAllDownloads() async {
try {
final String result = await _channel.invokeMethod('getAllDownloads');
final List<dynamic> jsonList = jsonDecode(result);
return jsonList
.map((json) => DownloadProgress.fromJson(json as Map<String, dynamic>))
.toList();
} on PlatformException catch (e) {
throw Exception('Failed to get all downloads: ${e.message}');
} catch (e) {
throw Exception('Failed to parse downloads: $e');
}
}
}

View File

@@ -77,6 +77,25 @@ class TorrentService {
return null; return null;
} }
/// Форматировать размер из байтов в читаемый формат
String formatFileSize(int? sizeInBytes) {
if (sizeInBytes == null || sizeInBytes == 0) return 'Неизвестно';
const int kb = 1024;
const int mb = kb * 1024;
const int gb = mb * 1024;
if (sizeInBytes >= gb) {
return '${(sizeInBytes / gb).toStringAsFixed(1)} GB';
} else if (sizeInBytes >= mb) {
return '${(sizeInBytes / mb).toStringAsFixed(0)} MB';
} else if (sizeInBytes >= kb) {
return '${(sizeInBytes / kb).toStringAsFixed(0)} KB';
} else {
return '$sizeInBytes B';
}
}
/// Группировать торренты по качеству /// Группировать торренты по качеству
Map<String, List<Torrent>> groupTorrentsByQuality(List<Torrent> torrents) { Map<String, List<Torrent>> groupTorrentsByQuality(List<Torrent> torrents) {
final groups = <String, List<Torrent>>{}; final groups = <String, List<Torrent>>{};

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../data/services/torrent_platform_service.dart';
class TorrentFileSelectorScreen extends StatefulWidget {
final String magnetLink;
final String torrentTitle;
const TorrentFileSelectorScreen({
super.key,
required this.magnetLink,
required this.torrentTitle,
});
@override
State<TorrentFileSelectorScreen> createState() => _TorrentFileSelectorScreenState();
}
class _TorrentFileSelectorScreenState extends State<TorrentFileSelectorScreen> {
TorrentMetadata? _metadata;
List<TorrentFileInfo> _files = [];
bool _isLoading = true;
String? _error;
bool _isDownloading = false;
bool _selectAll = false;
@override
void initState() {
super.initState();
_loadTorrentMetadata();
}
Future<void> _loadTorrentMetadata() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final metadata = await TorrentPlatformService.getTorrentMetadata(widget.magnetLink);
setState(() {
_metadata = metadata;
_files = metadata.files.map((file) => file.copyWith(selected: false)).toList();
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _toggleFileSelection(int index) {
setState(() {
_files[index] = _files[index].copyWith(selected: !_files[index].selected);
_updateSelectAllState();
});
}
void _toggleSelectAll() {
setState(() {
_selectAll = !_selectAll;
_files = _files.map((file) => file.copyWith(selected: _selectAll)).toList();
});
}
void _updateSelectAllState() {
final selectedCount = _files.where((file) => file.selected).length;
_selectAll = selectedCount == _files.length;
}
Future<void> _startDownload() async {
final selectedFiles = <int>[];
for (int i = 0; i < _files.length; i++) {
if (_files[i].selected) {
selectedFiles.add(i);
}
}
if (selectedFiles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Выберите хотя бы один файл для скачивания'),
backgroundColor: Colors.orange,
),
);
return;
}
setState(() {
_isDownloading = true;
});
try {
final infoHash = await TorrentPlatformService.startDownload(
magnetLink: widget.magnetLink,
selectedFiles: selectedFiles,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Скачивание начато! ID: ${infoHash.substring(0, 8)}...'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка скачивания: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isDownloading = false;
});
}
}
}
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';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Выбор файлов'),
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
scrolledUnderElevation: 1,
actions: [
if (!_isLoading && _files.isNotEmpty)
TextButton(
onPressed: _toggleSelectAll,
child: Text(_selectAll ? 'Снять все' : 'Выбрать все'),
),
],
),
body: Column(
children: [
// Header with torrent info
_buildTorrentHeader(),
// Content
Expanded(
child: _buildContent(),
),
// Download button
if (!_isLoading && _files.isNotEmpty) _buildDownloadButton(),
],
),
);
}
Widget _buildTorrentHeader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder_zip,
size: 24,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.torrentTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (_metadata != null) ...[
const SizedBox(height: 8),
Text(
'Общий размер: ${_formatFileSize(_metadata!.totalSize)} • Файлов: ${_metadata!.files.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
Widget _buildContent() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Получение информации о торренте...'),
],
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
SelectableText.rich(
TextSpan(
children: [
TextSpan(
text: 'Ошибка загрузки метаданных\n',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
TextSpan(
text: _error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _loadTorrentMetadata,
child: const Text('Повторить'),
),
],
),
),
);
}
if (_files.isEmpty) {
return const Center(
child: Text('Файлы не найдены'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _files.length,
itemBuilder: (context, index) {
final file = _files[index];
final isDirectory = file.path.contains('/');
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: CheckboxListTile(
value: file.selected,
onChanged: (_) => _toggleFileSelection(index),
title: Text(
file.path.split('/').last,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isDirectory) ...[
Text(
file.path.substring(0, file.path.lastIndexOf('/')),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
Text(
_formatFileSize(file.size),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
secondary: Icon(
_getFileIcon(file.path),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
controlAffinity: ListTileControlAffinity.leading,
),
);
},
);
}
Widget _buildDownloadButton() {
final selectedCount = _files.where((file) => file.selected).length;
final selectedSize = _files
.where((file) => file.selected)
.fold<int>(0, (sum, file) => sum + file.size);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedCount > 0) ...[
Text(
'Выбрано: $selectedCount файл(ов) • ${_formatFileSize(selectedSize)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isDownloading ? null : _startDownload,
icon: _isDownloading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text(_isDownloading ? 'Начинаем скачивание...' : 'Скачать выбранные'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
);
}
IconData _getFileIcon(String path) {
final extension = path.split('.').last.toLowerCase();
switch (extension) {
case 'mp4':
case 'mkv':
case 'avi':
case 'mov':
case 'wmv':
return Icons.movie;
case 'mp3':
case 'wav':
case 'flac':
case 'aac':
return Icons.music_note;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
return Icons.image;
case 'txt':
case 'nfo':
return Icons.description;
case 'srt':
case 'sub':
case 'ass':
return Icons.subtitles;
default:
return Icons.insert_drive_file;
}
}
}

View File

@@ -5,6 +5,7 @@ import '../../../data/models/torrent.dart';
import '../../../data/services/torrent_service.dart'; import '../../../data/services/torrent_service.dart';
import '../../cubits/torrent/torrent_cubit.dart'; import '../../cubits/torrent/torrent_cubit.dart';
import '../../cubits/torrent/torrent_state.dart'; import '../../cubits/torrent/torrent_state.dart';
import '../torrent_file_selector/torrent_file_selector_screen.dart';
class TorrentSelectorScreen extends StatefulWidget { class TorrentSelectorScreen extends StatefulWidget {
final String imdbId; final String imdbId;
@@ -338,7 +339,6 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
final title = torrent.title ?? torrent.name ?? 'Неизвестная раздача'; final title = torrent.title ?? torrent.name ?? 'Неизвестная раздача';
final quality = torrent.quality; final quality = torrent.quality;
final seeders = torrent.seeders; final seeders = torrent.seeders;
final sizeGb = torrent.sizeGb;
final isSelected = _selectedMagnet == torrent.magnet; final isSelected = _selectedMagnet == torrent.magnet;
return Card( return Card(
@@ -406,7 +406,7 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
if (sizeGb != null) ...[ if (torrent.size != null) ...[
Icon( Icon(
Icons.storage, Icons.storage,
size: 18, size: 18,
@@ -414,7 +414,7 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${sizeGb.toStringAsFixed(1)} GB', _formatFileSize(torrent.size),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -576,12 +576,24 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( Row(
width: double.infinity, children: [
child: FilledButton.icon( Expanded(
child: OutlinedButton.icon(
onPressed: _copyToClipboard, onPressed: _copyToClipboard,
icon: Icon(_isCopied ? Icons.check : Icons.copy), icon: Icon(_isCopied ? Icons.check : Icons.copy, size: 20),
label: Text(_isCopied ? 'Скопировано!' : 'Копировать magnet-ссылку'), label: Text(_isCopied ? 'Скопировано!' : 'Копировать'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _openFileSelector,
icon: const Icon(Icons.download, size: 20),
label: const Text('Скачать'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
), ),
@@ -589,10 +601,43 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
), ),
], ],
), ),
],
),
), ),
); );
} }
String _formatFileSize(int? sizeInBytes) {
if (sizeInBytes == null || sizeInBytes == 0) return 'Неизвестно';
const int kb = 1024;
const int mb = kb * 1024;
const int gb = mb * 1024;
if (sizeInBytes >= gb) {
return '${(sizeInBytes / gb).toStringAsFixed(1)} GB';
} else if (sizeInBytes >= mb) {
return '${(sizeInBytes / mb).toStringAsFixed(0)} MB';
} else if (sizeInBytes >= kb) {
return '${(sizeInBytes / kb).toStringAsFixed(0)} KB';
} else {
return '$sizeInBytes B';
}
}
void _openFileSelector() {
if (_selectedMagnet != null) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TorrentFileSelectorScreen(
magnetLink: _selectedMagnet!,
torrentTitle: widget.title,
),
),
);
}
}
void _copyToClipboard() { void _copyToClipboard() {
if (_selectedMagnet != null) { if (_selectedMagnet != null) {
Clipboard.setData(ClipboardData(text: _selectedMagnet!)); Clipboard.setData(ClipboardData(text: _selectedMagnet!));

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Глобальный менеджер фокуса для управления навигацией между элементами интерфейса
class GlobalFocusManager {
static final GlobalFocusManager _instance = GlobalFocusManager._internal();
factory GlobalFocusManager() => _instance;
GlobalFocusManager._internal();
// Фокус ноды для разных элементов интерфейса
FocusNode? _appBarFocusNode;
FocusNode? _contentFocusNode;
FocusNode? _bottomNavFocusNode;
// Текущее состояние фокуса
FocusArea _currentFocusArea = FocusArea.content;
// Callback для уведомления об изменении фокуса
VoidCallback? _onFocusChanged;
void initialize({
FocusNode? appBarFocusNode,
FocusNode? contentFocusNode,
FocusNode? bottomNavFocusNode,
VoidCallback? onFocusChanged,
}) {
_appBarFocusNode = appBarFocusNode;
_contentFocusNode = contentFocusNode;
_bottomNavFocusNode = bottomNavFocusNode;
_onFocusChanged = onFocusChanged;
}
/// Обработка глобальных клавиш
KeyEventResult handleGlobalKey(KeyEvent event) {
if (event is KeyDownEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.escape:
case LogicalKeyboardKey.goBack:
_focusAppBar();
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
if (_currentFocusArea == FocusArea.appBar) {
_focusContent();
return KeyEventResult.handled;
}
break;
case LogicalKeyboardKey.arrowDown:
if (_currentFocusArea == FocusArea.content) {
_focusBottomNav();
return KeyEventResult.handled;
} else if (_currentFocusArea == FocusArea.appBar) {
_focusContent();
return KeyEventResult.handled;
}
break;
}
}
return KeyEventResult.ignored;
}
void _focusAppBar() {
if (_appBarFocusNode != null) {
_currentFocusArea = FocusArea.appBar;
_appBarFocusNode!.requestFocus();
_onFocusChanged?.call();
}
}
void _focusContent() {
if (_contentFocusNode != null) {
_currentFocusArea = FocusArea.content;
_contentFocusNode!.requestFocus();
_onFocusChanged?.call();
}
}
void _focusBottomNav() {
if (_bottomNavFocusNode != null) {
_currentFocusArea = FocusArea.bottomNav;
_bottomNavFocusNode!.requestFocus();
_onFocusChanged?.call();
}
}
/// Установить фокус на контент (для использования извне)
void focusContent() => _focusContent();
/// Установить фокус на навбар (для использования извне)
void focusAppBar() => _focusAppBar();
/// Получить текущую область фокуса
FocusArea get currentFocusArea => _currentFocusArea;
/// Проверить, находится ли фокус в контенте
bool get isContentFocused => _currentFocusArea == FocusArea.content;
/// Проверить, находится ли фокус в навбаре
bool get isAppBarFocused => _currentFocusArea == FocusArea.appBar;
void dispose() {
_appBarFocusNode = null;
_contentFocusNode = null;
_bottomNavFocusNode = null;
_onFocusChanged = null;
}
}
/// Области фокуса в приложении
enum FocusArea {
appBar,
content,
bottomNav,
}
/// Виджет-обертка для глобального управления фокусом
class GlobalFocusWrapper extends StatefulWidget {
final Widget child;
final FocusNode? contentFocusNode;
const GlobalFocusWrapper({
super.key,
required this.child,
this.contentFocusNode,
});
@override
State<GlobalFocusWrapper> createState() => _GlobalFocusWrapperState();
}
class _GlobalFocusWrapperState extends State<GlobalFocusWrapper> {
final GlobalFocusManager _focusManager = GlobalFocusManager();
late final FocusNode _wrapperFocusNode;
@override
void initState() {
super.initState();
_wrapperFocusNode = FocusNode();
// Инициализируем глобальный менеджер фокуса
_focusManager.initialize(
contentFocusNode: widget.contentFocusNode ?? _wrapperFocusNode,
onFocusChanged: () => setState(() {}),
);
}
@override
void dispose() {
_wrapperFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _wrapperFocusNode,
onKeyEvent: (node, event) => _focusManager.handleGlobalKey(event),
child: widget.child,
);
}
}