From de26fd3fc9e6c4844daceb4e443023bd5e6724b0 Mon Sep 17 00:00:00 2001 From: Foxix Date: Sat, 19 Jul 2025 20:50:26 +0300 Subject: [PATCH] torrent downloads --- .../com/neo/neomovies_mobile/MainActivity.kt | 128 ++++++ .../neo/neomovies_mobile/TorrentService.kt | 275 ++++++++++++ lib/data/models/torrent.dart | 4 +- lib/data/models/torrent.freezed.dart | 43 +- lib/data/models/torrent.g.dart | 4 +- .../services/torrent_platform_service.dart | 223 ++++++++++ lib/data/services/torrent_service.dart | 19 + .../torrent_file_selector_screen.dart | 414 ++++++++++++++++++ .../torrent_selector_screen.dart | 69 ++- lib/utils/focus_manager.dart | 162 +++++++ 10 files changed, 1303 insertions(+), 38 deletions(-) create mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt create mode 100644 android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt create mode 100644 lib/data/services/torrent_platform_service.dart create mode 100644 lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart create mode 100644 lib/utils/focus_manager.dart diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt new file mode 100644 index 0000000..14b8b64 --- /dev/null +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/MainActivity.kt @@ -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("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("magnetLink") + val selectedFiles = call.argument>("selectedFiles") + val downloadPath = call.argument("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("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("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("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("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() + } + } +} diff --git a/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt new file mode 100644 index 0000000..bd6a8dc --- /dev/null +++ b/android/app/src/main/kotlin/com/neo/neomovies_mobile/TorrentService.kt @@ -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, + @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() + + 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 = 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() + + 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, + downloadPath: String? = null + ): Result = 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 { + 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 + } +} diff --git a/lib/data/models/torrent.dart b/lib/data/models/torrent.dart index a3c4b43..4b612a9 100644 --- a/lib/data/models/torrent.dart +++ b/lib/data/models/torrent.dart @@ -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 json) => _$TorrentFromJson(json); } + + diff --git a/lib/data/models/torrent.freezed.dart b/lib/data/models/torrent.freezed.dart index fff0e7a..c5678a3 100644 --- a/lib/data/models/torrent.freezed.dart +++ b/lib/data/models/torrent.freezed.dart @@ -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 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 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 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. diff --git a/lib/data/models/torrent.g.dart b/lib/data/models/torrent.g.dart index bdf9d4e..e1c1b88 100644 --- a/lib/data/models/torrent.g.dart +++ b/lib/data/models/torrent.g.dart @@ -13,7 +13,7 @@ _$TorrentImpl _$$TorrentImplFromJson(Map 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 _$$TorrentImplToJson(_$TorrentImpl instance) => @@ -23,5 +23,5 @@ Map _$$TorrentImplToJson(_$TorrentImpl instance) => 'name': instance.name, 'quality': instance.quality, 'seeders': instance.seeders, - 'size_gb': instance.sizeGb, + 'size': instance.size, }; diff --git a/lib/data/services/torrent_platform_service.dart b/lib/data/services/torrent_platform_service.dart new file mode 100644 index 0000000..c7be49c --- /dev/null +++ b/lib/data/services/torrent_platform_service.dart @@ -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 json) { + return TorrentFileInfo( + path: json['path'] as String, + size: json['size'] as int, + selected: json['selected'] as bool? ?? false, + ); + } + + Map 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 files; + final String infoHash; + + TorrentMetadata({ + required this.name, + required this.totalSize, + required this.files, + required this.infoHash, + }); + + factory TorrentMetadata.fromJson(Map 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)) + .toList(), + infoHash: json['infoHash'] as String, + ); + } + + Map 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 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 getTorrentMetadata(String magnetLink) async { + try { + final String result = await _channel.invokeMethod('getTorrentMetadata', { + 'magnetLink': magnetLink, + }); + + final Map 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 startDownload({ + required String magnetLink, + required List 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 getDownloadProgress(String infoHash) async { + try { + final String? result = await _channel.invokeMethod('getDownloadProgress', { + 'infoHash': infoHash, + }); + + if (result == null) return null; + + final Map 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 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 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 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> getAllDownloads() async { + try { + final String result = await _channel.invokeMethod('getAllDownloads'); + + final List jsonList = jsonDecode(result); + return jsonList + .map((json) => DownloadProgress.fromJson(json as Map)) + .toList(); + } on PlatformException catch (e) { + throw Exception('Failed to get all downloads: ${e.message}'); + } catch (e) { + throw Exception('Failed to parse downloads: $e'); + } + } +} diff --git a/lib/data/services/torrent_service.dart b/lib/data/services/torrent_service.dart index 3b42e52..2dfd416 100644 --- a/lib/data/services/torrent_service.dart +++ b/lib/data/services/torrent_service.dart @@ -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> groupTorrentsByQuality(List torrents) { final groups = >{}; diff --git a/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart b/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart new file mode 100644 index 0000000..2dbaab9 --- /dev/null +++ b/lib/presentation/screens/torrent_file_selector/torrent_file_selector_screen.dart @@ -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 createState() => _TorrentFileSelectorScreenState(); +} + +class _TorrentFileSelectorScreenState extends State { + TorrentMetadata? _metadata; + List _files = []; + bool _isLoading = true; + String? _error; + bool _isDownloading = false; + bool _selectAll = false; + + @override + void initState() { + super.initState(); + _loadTorrentMetadata(); + } + + Future _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 _startDownload() async { + final selectedFiles = []; + 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(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; + } + } +} diff --git a/lib/presentation/screens/torrent_selector/torrent_selector_screen.dart b/lib/presentation/screens/torrent_selector/torrent_selector_screen.dart index 2509d75..23fd3eb 100644 --- a/lib/presentation/screens/torrent_selector/torrent_selector_screen.dart +++ b/lib/presentation/screens/torrent_selector/torrent_selector_screen.dart @@ -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 { 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 { ), const SizedBox(width: 16), ], - if (sizeGb != null) ...[ + if (torrent.size != null) ...[ Icon( Icons.storage, size: 18, @@ -414,7 +414,7 @@ class _TorrentSelectorScreenState extends State { ), 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 { ), ), 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 { ); } + 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!)); diff --git a/lib/utils/focus_manager.dart b/lib/utils/focus_manager.dart new file mode 100644 index 0000000..b1e7c8e --- /dev/null +++ b/lib/utils/focus_manager.dart @@ -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 createState() => _GlobalFocusWrapperState(); +} + +class _GlobalFocusWrapperState extends State { + 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, + ); + } +}