mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:18:50 +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? 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
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>>{};
|
||||||
|
|||||||
@@ -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 '../../../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!));
|
||||||
|
|||||||
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