mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-27 17:18:51 +05:00
torrent downloads
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ class Torrent with _$Torrent {
|
||||
String? name,
|
||||
String? quality,
|
||||
int? seeders,
|
||||
@JsonKey(name: 'size_gb') double? sizeGb,
|
||||
int? size, // размер в байтах
|
||||
}) = _Torrent;
|
||||
|
||||
factory Torrent.fromJson(Map<String, dynamic> json) => _$TorrentFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ mixin _$Torrent {
|
||||
String? get name => throw _privateConstructorUsedError;
|
||||
String? get quality => throw _privateConstructorUsedError;
|
||||
int? get seeders => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'size_gb')
|
||||
double? get sizeGb => throw _privateConstructorUsedError;
|
||||
int? get size => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this Torrent to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@@ -48,7 +47,7 @@ abstract class $TorrentCopyWith<$Res> {
|
||||
String? name,
|
||||
String? quality,
|
||||
int? seeders,
|
||||
@JsonKey(name: 'size_gb') double? sizeGb});
|
||||
int? size});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@@ -71,7 +70,7 @@ class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
|
||||
Object? name = freezed,
|
||||
Object? quality = freezed,
|
||||
Object? seeders = freezed,
|
||||
Object? sizeGb = freezed,
|
||||
Object? size = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
magnet: null == magnet
|
||||
@@ -94,10 +93,10 @@ class _$TorrentCopyWithImpl<$Res, $Val extends Torrent>
|
||||
? _value.seeders
|
||||
: seeders // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
sizeGb: freezed == sizeGb
|
||||
? _value.sizeGb
|
||||
: sizeGb // ignore: cast_nullable_to_non_nullable
|
||||
as double?,
|
||||
size: freezed == size
|
||||
? _value.size
|
||||
: size // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
@@ -115,7 +114,7 @@ abstract class _$$TorrentImplCopyWith<$Res> implements $TorrentCopyWith<$Res> {
|
||||
String? name,
|
||||
String? quality,
|
||||
int? seeders,
|
||||
@JsonKey(name: 'size_gb') double? sizeGb});
|
||||
int? size});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@@ -136,7 +135,7 @@ class __$$TorrentImplCopyWithImpl<$Res>
|
||||
Object? name = freezed,
|
||||
Object? quality = freezed,
|
||||
Object? seeders = freezed,
|
||||
Object? sizeGb = freezed,
|
||||
Object? size = freezed,
|
||||
}) {
|
||||
return _then(_$TorrentImpl(
|
||||
magnet: null == magnet
|
||||
@@ -159,10 +158,10 @@ class __$$TorrentImplCopyWithImpl<$Res>
|
||||
? _value.seeders
|
||||
: seeders // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
sizeGb: freezed == sizeGb
|
||||
? _value.sizeGb
|
||||
: sizeGb // ignore: cast_nullable_to_non_nullable
|
||||
as double?,
|
||||
size: freezed == size
|
||||
? _value.size
|
||||
: size // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -176,7 +175,7 @@ class _$TorrentImpl implements _Torrent {
|
||||
this.name,
|
||||
this.quality,
|
||||
this.seeders,
|
||||
@JsonKey(name: 'size_gb') this.sizeGb});
|
||||
this.size});
|
||||
|
||||
factory _$TorrentImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TorrentImplFromJson(json);
|
||||
@@ -192,12 +191,11 @@ class _$TorrentImpl implements _Torrent {
|
||||
@override
|
||||
final int? seeders;
|
||||
@override
|
||||
@JsonKey(name: 'size_gb')
|
||||
final double? sizeGb;
|
||||
final int? size;
|
||||
|
||||
@override
|
||||
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
|
||||
@@ -210,13 +208,13 @@ class _$TorrentImpl implements _Torrent {
|
||||
(identical(other.name, name) || other.name == name) &&
|
||||
(identical(other.quality, quality) || other.quality == quality) &&
|
||||
(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)
|
||||
@override
|
||||
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
|
||||
/// 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? quality,
|
||||
final int? seeders,
|
||||
@JsonKey(name: 'size_gb') final double? sizeGb}) = _$TorrentImpl;
|
||||
final int? size}) = _$TorrentImpl;
|
||||
|
||||
factory _Torrent.fromJson(Map<String, dynamic> json) = _$TorrentImpl.fromJson;
|
||||
|
||||
@@ -256,8 +254,7 @@ abstract class _Torrent implements Torrent {
|
||||
@override
|
||||
int? get seeders;
|
||||
@override
|
||||
@JsonKey(name: 'size_gb')
|
||||
double? get sizeGb;
|
||||
int? get size;
|
||||
|
||||
/// Create a copy of Torrent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
||||
@@ -13,7 +13,7 @@ _$TorrentImpl _$$TorrentImplFromJson(Map<String, dynamic> json) =>
|
||||
name: json['name'] as String?,
|
||||
quality: json['quality'] as String?,
|
||||
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) =>
|
||||
@@ -23,5 +23,5 @@ Map<String, dynamic> _$$TorrentImplToJson(_$TorrentImpl instance) =>
|
||||
'name': instance.name,
|
||||
'quality': instance.quality,
|
||||
'seeders': instance.seeders,
|
||||
'size_gb': instance.sizeGb,
|
||||
'size': instance.size,
|
||||
};
|
||||
|
||||
223
lib/data/services/torrent_platform_service.dart
Normal file
223
lib/data/services/torrent_platform_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,25 @@ class TorrentService {
|
||||
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) {
|
||||
final groups = <String, List<Torrent>>{};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../../../data/models/torrent.dart';
|
||||
import '../../../data/services/torrent_service.dart';
|
||||
import '../../cubits/torrent/torrent_cubit.dart';
|
||||
import '../../cubits/torrent/torrent_state.dart';
|
||||
import '../torrent_file_selector/torrent_file_selector_screen.dart';
|
||||
|
||||
class TorrentSelectorScreen extends StatefulWidget {
|
||||
final String imdbId;
|
||||
@@ -338,7 +339,6 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
|
||||
final title = torrent.title ?? torrent.name ?? 'Неизвестная раздача';
|
||||
final quality = torrent.quality;
|
||||
final seeders = torrent.seeders;
|
||||
final sizeGb = torrent.sizeGb;
|
||||
final isSelected = _selectedMagnet == torrent.magnet;
|
||||
|
||||
return Card(
|
||||
@@ -406,7 +406,7 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
if (sizeGb != null) ...[
|
||||
if (torrent.size != null) ...[
|
||||
Icon(
|
||||
Icons.storage,
|
||||
size: 18,
|
||||
@@ -414,7 +414,7 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${sizeGb.toStringAsFixed(1)} GB',
|
||||
_formatFileSize(torrent.size),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -576,16 +576,30 @@ class _TorrentSelectorScreenState extends State<TorrentSelectorScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _copyToClipboard,
|
||||
icon: Icon(_isCopied ? Icons.check : Icons.copy),
|
||||
label: Text(_isCopied ? 'Скопировано!' : 'Копировать magnet-ссылку'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _copyToClipboard,
|
||||
icon: Icon(_isCopied ? Icons.check : Icons.copy, size: 20),
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -593,6 +607,37 @@ 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() {
|
||||
if (_selectedMagnet != null) {
|
||||
Clipboard.setData(ClipboardData(text: _selectedMagnet!));
|
||||
|
||||
162
lib/utils/focus_manager.dart
Normal file
162
lib/utils/focus_manager.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user