mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-27 19:58:50 +05:00
feat: Implement comprehensive torrent downloads management system
- Fix torrent platform service integration with Android engine - Add downloads page with torrent list and progress tracking - Implement torrent detail screen with file selection and priorities - Create native video player with fullscreen controls - Add WebView players for Vibix and Alloha - Integrate corrected torrent engine with file selector - Update dependencies for auto_route and video players Features: ✅ Downloads screen with real-time torrent status ✅ File-level priority management and selection ✅ Three player options: native, Vibix WebView, Alloha WebView ✅ Torrent pause/resume/remove functionality ✅ Progress tracking and seeder/peer counts ✅ Video file detection and playback integration ✅ Fixed Android torrent engine method calls This resolves torrent integration issues and provides complete downloads management UI with video playback capabilities.
This commit is contained in:
180
lib/data/models/torrent_info.dart
Normal file
180
lib/data/models/torrent_info.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/// File priority enum matching Android implementation
|
||||||
|
enum FilePriority {
|
||||||
|
DONT_DOWNLOAD(0),
|
||||||
|
NORMAL(4),
|
||||||
|
HIGH(7);
|
||||||
|
|
||||||
|
const FilePriority(this.value);
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
static FilePriority fromValue(int value) {
|
||||||
|
return FilePriority.values.firstWhere(
|
||||||
|
(priority) => priority.value == value,
|
||||||
|
orElse: () => FilePriority.NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator >(FilePriority other) => value > other.value;
|
||||||
|
bool operator <(FilePriority other) => value < other.value;
|
||||||
|
bool operator >=(FilePriority other) => value >= other.value;
|
||||||
|
bool operator <=(FilePriority other) => value <= other.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Torrent file information matching Android TorrentFileInfo
|
||||||
|
class TorrentFileInfo {
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final FilePriority priority;
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
TorrentFileInfo({
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
required this.priority,
|
||||||
|
this.progress = 0.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentFileInfo.fromAndroidJson(Map<String, dynamic> json) {
|
||||||
|
return TorrentFileInfo(
|
||||||
|
path: json['path'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
priority: FilePriority.fromValue(json['priority'] as int),
|
||||||
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'path': path,
|
||||||
|
'size': size,
|
||||||
|
'priority': priority.value,
|
||||||
|
'progress': progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main torrent information class matching Android TorrentInfo
|
||||||
|
class TorrentInfo {
|
||||||
|
final String infoHash;
|
||||||
|
final String name;
|
||||||
|
final int totalSize;
|
||||||
|
final double progress;
|
||||||
|
final int downloadSpeed;
|
||||||
|
final int uploadSpeed;
|
||||||
|
final int numSeeds;
|
||||||
|
final int numPeers;
|
||||||
|
final String state;
|
||||||
|
final String savePath;
|
||||||
|
final List<TorrentFileInfo> files;
|
||||||
|
final int pieceLength;
|
||||||
|
final int numPieces;
|
||||||
|
final DateTime? addedTime;
|
||||||
|
|
||||||
|
TorrentInfo({
|
||||||
|
required this.infoHash,
|
||||||
|
required this.name,
|
||||||
|
required this.totalSize,
|
||||||
|
required this.progress,
|
||||||
|
required this.downloadSpeed,
|
||||||
|
required this.uploadSpeed,
|
||||||
|
required this.numSeeds,
|
||||||
|
required this.numPeers,
|
||||||
|
required this.state,
|
||||||
|
required this.savePath,
|
||||||
|
required this.files,
|
||||||
|
this.pieceLength = 0,
|
||||||
|
this.numPieces = 0,
|
||||||
|
this.addedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TorrentInfo.fromAndroidJson(Map<String, dynamic> json) {
|
||||||
|
final filesJson = json['files'] as List<dynamic>? ?? [];
|
||||||
|
final files = filesJson
|
||||||
|
.map((fileJson) => TorrentFileInfo.fromAndroidJson(fileJson as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return TorrentInfo(
|
||||||
|
infoHash: json['infoHash'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
totalSize: json['totalSize'] as int,
|
||||||
|
progress: (json['progress'] as num).toDouble(),
|
||||||
|
downloadSpeed: json['downloadSpeed'] as int,
|
||||||
|
uploadSpeed: json['uploadSpeed'] as int,
|
||||||
|
numSeeds: json['numSeeds'] as int,
|
||||||
|
numPeers: json['numPeers'] as int,
|
||||||
|
state: json['state'] as String,
|
||||||
|
savePath: json['savePath'] as String,
|
||||||
|
files: files,
|
||||||
|
pieceLength: json['pieceLength'] as int? ?? 0,
|
||||||
|
numPieces: json['numPieces'] as int? ?? 0,
|
||||||
|
addedTime: json['addedTime'] != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(json['addedTime'] as int)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'name': name,
|
||||||
|
'totalSize': totalSize,
|
||||||
|
'progress': progress,
|
||||||
|
'downloadSpeed': downloadSpeed,
|
||||||
|
'uploadSpeed': uploadSpeed,
|
||||||
|
'numSeeds': numSeeds,
|
||||||
|
'numPeers': numPeers,
|
||||||
|
'state': state,
|
||||||
|
'savePath': savePath,
|
||||||
|
'files': files.map((file) => file.toJson()).toList(),
|
||||||
|
'pieceLength': pieceLength,
|
||||||
|
'numPieces': numPieces,
|
||||||
|
'addedTime': addedTime?.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get video files only
|
||||||
|
List<TorrentFileInfo> get videoFiles {
|
||||||
|
final videoExtensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v'};
|
||||||
|
return files.where((file) {
|
||||||
|
final extension = file.path.toLowerCase().split('.').last;
|
||||||
|
return videoExtensions.contains('.$extension');
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the largest video file (usually the main movie file)
|
||||||
|
TorrentFileInfo? get mainVideoFile {
|
||||||
|
final videos = videoFiles;
|
||||||
|
if (videos.isEmpty) return null;
|
||||||
|
|
||||||
|
videos.sort((a, b) => b.size.compareTo(a.size));
|
||||||
|
return videos.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if torrent is completed
|
||||||
|
bool get isCompleted => progress >= 1.0;
|
||||||
|
|
||||||
|
/// Check if torrent is downloading
|
||||||
|
bool get isDownloading => state == 'DOWNLOADING';
|
||||||
|
|
||||||
|
/// Check if torrent is seeding
|
||||||
|
bool get isSeeding => state == 'SEEDING';
|
||||||
|
|
||||||
|
/// Check if torrent is paused
|
||||||
|
bool get isPaused => state == 'PAUSED';
|
||||||
|
|
||||||
|
/// Get formatted download speed
|
||||||
|
String get formattedDownloadSpeed => _formatBytes(downloadSpeed);
|
||||||
|
|
||||||
|
/// Get formatted upload speed
|
||||||
|
String get formattedUploadSpeed => _formatBytes(uploadSpeed);
|
||||||
|
|
||||||
|
/// Get formatted total size
|
||||||
|
String get formattedTotalSize => _formatBytes(totalSize);
|
||||||
|
|
||||||
|
static String _formatBytes(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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../models/torrent_info.dart';
|
||||||
|
|
||||||
/// Data classes for torrent metadata (matching Kotlin side)
|
/// Data classes for torrent metadata (matching Kotlin side)
|
||||||
|
|
||||||
@@ -340,106 +341,89 @@ class DownloadProgress {
|
|||||||
class TorrentPlatformService {
|
class TorrentPlatformService {
|
||||||
static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent');
|
static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent');
|
||||||
|
|
||||||
/// Получить базовую информацию из magnet-ссылки
|
/// Add torrent from magnet URI and start downloading
|
||||||
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
static Future<String> addTorrent({
|
||||||
try {
|
required String magnetUri,
|
||||||
final String result = await _channel.invokeMethod('parseMagnetBasicInfo', {
|
String? savePath,
|
||||||
'magnetUri': magnetUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, dynamic> json = jsonDecode(result);
|
|
||||||
return MagnetBasicInfo.fromJson(json);
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception('Failed to parse magnet URI: ${e.message}');
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Failed to parse magnet basic info: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить полные метаданные торрента
|
|
||||||
static Future<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
|
|
||||||
try {
|
|
||||||
final String result = await _channel.invokeMethod('fetchFullMetadata', {
|
|
||||||
'magnetUri': magnetUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, dynamic> json = jsonDecode(result);
|
|
||||||
return TorrentMetadataFull.fromJson(json);
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception('Failed to fetch torrent metadata: ${e.message}');
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception('Failed to parse torrent metadata: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Тестирование торрент-сервиса
|
|
||||||
static Future<String> testTorrentService() async {
|
|
||||||
try {
|
|
||||||
final String result = await _channel.invokeMethod('testTorrentService');
|
|
||||||
return result;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception('Torrent service test failed: ${e.message}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get torrent metadata from magnet link (legacy method)
|
|
||||||
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 {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final String infoHash = await _channel.invokeMethod('startDownload', {
|
final String infoHash = await _channel.invokeMethod('addTorrent', {
|
||||||
'magnetLink': magnetLink,
|
'magnetUri': magnetUri,
|
||||||
'selectedFiles': selectedFiles,
|
'savePath': savePath ?? '/storage/emulated/0/Download/NeoMovies',
|
||||||
'downloadPath': downloadPath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return infoHash;
|
return infoHash;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
throw Exception('Failed to start download: ${e.message}');
|
throw Exception('Failed to add torrent: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all torrents
|
||||||
|
static Future<List<DownloadProgress>> getAllDownloads() async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('getTorrents');
|
||||||
|
|
||||||
|
final List<dynamic> jsonList = jsonDecode(result);
|
||||||
|
return jsonList.map((json) {
|
||||||
|
final data = json as Map<String, dynamic>;
|
||||||
|
return DownloadProgress(
|
||||||
|
infoHash: data['infoHash'] as String,
|
||||||
|
progress: (data['progress'] as num).toDouble(),
|
||||||
|
downloadRate: data['downloadSpeed'] as int,
|
||||||
|
uploadRate: data['uploadSpeed'] as int,
|
||||||
|
numSeeds: data['numSeeds'] as int,
|
||||||
|
numPeers: data['numPeers'] as int,
|
||||||
|
state: data['state'] as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception('Failed to get all downloads: ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse downloads: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single torrent info
|
||||||
|
static Future<TorrentInfo?> getTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final String result = await _channel.invokeMethod('getTorrent', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> json = jsonDecode(result);
|
||||||
|
return TorrentInfo.fromAndroidJson(json);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == 'NOT_FOUND') return null;
|
||||||
|
throw Exception('Failed to get torrent: ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse torrent: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get download progress for a torrent
|
/// Get download progress for a torrent
|
||||||
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
|
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
|
||||||
try {
|
try {
|
||||||
final String? result = await _channel.invokeMethod('getDownloadProgress', {
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
'infoHash': infoHash,
|
if (torrentInfo == null) return null;
|
||||||
});
|
|
||||||
|
|
||||||
if (result == null) return null;
|
return DownloadProgress(
|
||||||
|
infoHash: torrentInfo.infoHash,
|
||||||
final Map<String, dynamic> json = jsonDecode(result);
|
progress: torrentInfo.progress,
|
||||||
return DownloadProgress.fromJson(json);
|
downloadRate: torrentInfo.downloadSpeed,
|
||||||
} on PlatformException catch (e) {
|
uploadRate: torrentInfo.uploadSpeed,
|
||||||
if (e.code == 'NOT_FOUND') return null;
|
numSeeds: torrentInfo.numSeeds,
|
||||||
throw Exception('Failed to get download progress: ${e.message}');
|
numPeers: torrentInfo.numPeers,
|
||||||
|
state: torrentInfo.state,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to parse download progress: $e');
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause download
|
/// Pause download
|
||||||
static Future<bool> pauseDownload(String infoHash) async {
|
static Future<bool> pauseDownload(String infoHash) async {
|
||||||
try {
|
try {
|
||||||
final bool result = await _channel.invokeMethod('pauseDownload', {
|
final bool result = await _channel.invokeMethod('pauseTorrent', {
|
||||||
'infoHash': infoHash,
|
'infoHash': infoHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -452,7 +436,7 @@ class TorrentPlatformService {
|
|||||||
/// Resume download
|
/// Resume download
|
||||||
static Future<bool> resumeDownload(String infoHash) async {
|
static Future<bool> resumeDownload(String infoHash) async {
|
||||||
try {
|
try {
|
||||||
final bool result = await _channel.invokeMethod('resumeDownload', {
|
final bool result = await _channel.invokeMethod('resumeTorrent', {
|
||||||
'infoHash': infoHash,
|
'infoHash': infoHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -465,8 +449,9 @@ class TorrentPlatformService {
|
|||||||
/// Cancel and remove download
|
/// Cancel and remove download
|
||||||
static Future<bool> cancelDownload(String infoHash) async {
|
static Future<bool> cancelDownload(String infoHash) async {
|
||||||
try {
|
try {
|
||||||
final bool result = await _channel.invokeMethod('cancelDownload', {
|
final bool result = await _channel.invokeMethod('removeTorrent', {
|
||||||
'infoHash': infoHash,
|
'infoHash': infoHash,
|
||||||
|
'deleteFiles': true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -475,19 +460,138 @@ class TorrentPlatformService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all active downloads
|
/// Set file priority
|
||||||
static Future<List<DownloadProgress>> getAllDownloads() async {
|
static Future<bool> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||||||
try {
|
try {
|
||||||
final String result = await _channel.invokeMethod('getAllDownloads');
|
final bool result = await _channel.invokeMethod('setFilePriority', {
|
||||||
|
'infoHash': infoHash,
|
||||||
|
'fileIndex': fileIndex,
|
||||||
|
'priority': priority.value,
|
||||||
|
});
|
||||||
|
|
||||||
final List<dynamic> jsonList = jsonDecode(result);
|
return result;
|
||||||
return jsonList
|
|
||||||
.map((json) => DownloadProgress.fromJson(json as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
throw Exception('Failed to get all downloads: ${e.message}');
|
throw Exception('Failed to set file priority: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading selected files from torrent
|
||||||
|
static Future<String> startDownload({
|
||||||
|
required String magnetLink,
|
||||||
|
required List<int> selectedFiles,
|
||||||
|
String? downloadPath,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// First add the torrent
|
||||||
|
final String infoHash = await addTorrent(
|
||||||
|
magnetUri: magnetLink,
|
||||||
|
savePath: downloadPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for metadata to be received
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Set file priorities
|
||||||
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
|
if (torrentInfo != null) {
|
||||||
|
for (int i = 0; i < torrentInfo.files.length; i++) {
|
||||||
|
final priority = selectedFiles.contains(i)
|
||||||
|
? FilePriority.NORMAL
|
||||||
|
: FilePriority.DONT_DOWNLOAD;
|
||||||
|
await setFilePriority(infoHash, i, priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return infoHash;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to parse downloads: $e');
|
throw Exception('Failed to start download: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy methods for compatibility with existing code
|
||||||
|
|
||||||
|
/// Get torrent metadata from magnet link (legacy method)
|
||||||
|
static Future<TorrentMetadata> getTorrentMetadata(String magnetLink) async {
|
||||||
|
try {
|
||||||
|
// This is a simplified implementation that adds the torrent and gets metadata
|
||||||
|
final infoHash = await addTorrent(magnetUri: magnetLink);
|
||||||
|
await Future.delayed(const Duration(seconds: 3)); // Wait for metadata
|
||||||
|
|
||||||
|
final torrentInfo = await getTorrent(infoHash);
|
||||||
|
if (torrentInfo == null) {
|
||||||
|
throw Exception('Failed to get torrent metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
return TorrentMetadata(
|
||||||
|
name: torrentInfo.name,
|
||||||
|
totalSize: torrentInfo.totalSize,
|
||||||
|
files: torrentInfo.files.map((file) => TorrentFileInfo(
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
selected: file.priority > FilePriority.DONT_DOWNLOAD,
|
||||||
|
)).toList(),
|
||||||
|
infoHash: torrentInfo.infoHash,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get torrent metadata: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить базовую информацию из magnet-ссылки (legacy)
|
||||||
|
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
// Parse magnet URI manually since Android implementation doesn't have this
|
||||||
|
final uri = Uri.parse(magnetUri);
|
||||||
|
final params = uri.queryParameters;
|
||||||
|
|
||||||
|
return MagnetBasicInfo(
|
||||||
|
name: params['dn'] ?? 'Unknown',
|
||||||
|
infoHash: params['xt']?.replaceFirst('urn:btih:', '') ?? '',
|
||||||
|
trackers: params['tr'] != null ? [params['tr']!] : [],
|
||||||
|
totalSize: 0,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse magnet basic info: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить полные метаданные торрента (legacy)
|
||||||
|
static Future<TorrentMetadataFull> fetchFullMetadata(String magnetUri) async {
|
||||||
|
try {
|
||||||
|
final basicInfo = await parseMagnetBasicInfo(magnetUri);
|
||||||
|
final metadata = await getTorrentMetadata(magnetUri);
|
||||||
|
|
||||||
|
return TorrentMetadataFull(
|
||||||
|
name: metadata.name,
|
||||||
|
infoHash: metadata.infoHash,
|
||||||
|
totalSize: metadata.totalSize,
|
||||||
|
pieceLength: 0,
|
||||||
|
numPieces: 0,
|
||||||
|
fileStructure: FileStructure(
|
||||||
|
rootDirectory: DirectoryNode(
|
||||||
|
name: metadata.name,
|
||||||
|
path: '/',
|
||||||
|
files: metadata.files.map((file) => FileInfo(
|
||||||
|
name: file.path.split('/').last,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
index: metadata.files.indexOf(file),
|
||||||
|
)).toList(),
|
||||||
|
subdirectories: [],
|
||||||
|
totalSize: metadata.totalSize,
|
||||||
|
fileCount: metadata.files.length,
|
||||||
|
),
|
||||||
|
totalFiles: metadata.files.length,
|
||||||
|
filesByType: {'video': metadata.files.length},
|
||||||
|
),
|
||||||
|
trackers: basicInfo.trackers,
|
||||||
|
creationDate: 0,
|
||||||
|
comment: '',
|
||||||
|
createdBy: '',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to fetch full metadata: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
171
lib/presentation/providers/downloads_provider.dart
Normal file
171
lib/presentation/providers/downloads_provider.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/services/torrent_platform_service.dart';
|
||||||
|
import '../../data/models/torrent_info.dart';
|
||||||
|
|
||||||
|
/// Provider для управления загрузками торрентов
|
||||||
|
class DownloadsProvider with ChangeNotifier {
|
||||||
|
final List<TorrentInfo> _torrents = [];
|
||||||
|
Timer? _progressTimer;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
DownloadsProvider() {
|
||||||
|
_startProgressUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startProgressUpdates() {
|
||||||
|
_progressTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||||
|
if (_torrents.isNotEmpty && !_isLoading) {
|
||||||
|
refreshDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузить список активных загрузок
|
||||||
|
Future<void> refreshDownloads() async {
|
||||||
|
try {
|
||||||
|
_setLoading(true);
|
||||||
|
_setError(null);
|
||||||
|
|
||||||
|
final progress = await TorrentPlatformService.getAllDownloads();
|
||||||
|
|
||||||
|
// Получаем полную информацию о каждом торренте
|
||||||
|
_torrents.clear();
|
||||||
|
for (final progressItem in progress) {
|
||||||
|
try {
|
||||||
|
final torrentInfo = await TorrentPlatformService.getTorrent(progressItem.infoHash);
|
||||||
|
if (torrentInfo != null) {
|
||||||
|
_torrents.add(torrentInfo);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Если не удалось получить полную информацию, создаем базовую
|
||||||
|
_torrents.add(TorrentInfo(
|
||||||
|
infoHash: progressItem.infoHash,
|
||||||
|
name: 'Торрент ${progressItem.infoHash.substring(0, 8)}',
|
||||||
|
totalSize: 0,
|
||||||
|
progress: progressItem.progress,
|
||||||
|
downloadSpeed: progressItem.downloadRate,
|
||||||
|
uploadSpeed: progressItem.uploadRate,
|
||||||
|
numSeeds: progressItem.numSeeds,
|
||||||
|
numPeers: progressItem.numPeers,
|
||||||
|
state: progressItem.state,
|
||||||
|
savePath: '/storage/emulated/0/Download/NeoMovies',
|
||||||
|
files: [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить информацию о конкретном торренте
|
||||||
|
Future<TorrentInfo?> getTorrentInfo(String infoHash) async {
|
||||||
|
try {
|
||||||
|
return await TorrentPlatformService.getTorrent(infoHash);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Ошибка получения информации о торренте: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Приостановить торрент
|
||||||
|
Future<void> pauseTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.pauseDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возобновить торрент
|
||||||
|
Future<void> resumeTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.resumeDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить торрент
|
||||||
|
Future<void> removeTorrent(String infoHash) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.cancelDownload(infoHash);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить приоритет файла
|
||||||
|
Future<void> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||||||
|
try {
|
||||||
|
await TorrentPlatformService.setFilePriority(infoHash, fileIndex, priority);
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавить новый торрент
|
||||||
|
Future<String?> addTorrent(String magnetUri, {String? savePath}) async {
|
||||||
|
try {
|
||||||
|
final infoHash = await TorrentPlatformService.addTorrent(
|
||||||
|
magnetUri: magnetUri,
|
||||||
|
savePath: savePath,
|
||||||
|
);
|
||||||
|
await refreshDownloads(); // Обновляем список
|
||||||
|
return infoHash;
|
||||||
|
} catch (e) {
|
||||||
|
_setError(e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматировать скорость
|
||||||
|
String formatSpeed(int bytesPerSecond) {
|
||||||
|
if (bytesPerSecond < 1024) return '${bytesPerSecond}B/s';
|
||||||
|
if (bytesPerSecond < 1024 * 1024) return '${(bytesPerSecond / 1024).toStringAsFixed(1)}KB/s';
|
||||||
|
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)}MB/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматировать продолжительность
|
||||||
|
String formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}ч ${minutes}м ${seconds}с';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}м ${seconds}с';
|
||||||
|
} else {
|
||||||
|
return '${seconds}с';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLoading(bool loading) {
|
||||||
|
_isLoading = loading;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(String? error) {
|
||||||
|
_error = error;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
lib/presentation/providers/downloads_provider_old.dart
Normal file
221
lib/presentation/providers/downloads_provider_old.dart
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/services/torrent_platform_service.dart';
|
||||||
|
import '../../data/models/torrent_info.dart';
|
||||||
|
|
||||||
|
class ActiveDownload {
|
||||||
|
final String infoHash;
|
||||||
|
final String name;
|
||||||
|
final DownloadProgress progress;
|
||||||
|
final DateTime startTime;
|
||||||
|
final List<String> selectedFiles;
|
||||||
|
|
||||||
|
ActiveDownload({
|
||||||
|
required this.infoHash,
|
||||||
|
required this.name,
|
||||||
|
required this.progress,
|
||||||
|
required this.startTime,
|
||||||
|
required this.selectedFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
ActiveDownload copyWith({
|
||||||
|
String? infoHash,
|
||||||
|
String? name,
|
||||||
|
DownloadProgress? progress,
|
||||||
|
DateTime? startTime,
|
||||||
|
List<String>? selectedFiles,
|
||||||
|
}) {
|
||||||
|
return ActiveDownload(
|
||||||
|
infoHash: infoHash ?? this.infoHash,
|
||||||
|
name: name ?? this.name,
|
||||||
|
progress: progress ?? this.progress,
|
||||||
|
startTime: startTime ?? this.startTime,
|
||||||
|
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadsProvider with ChangeNotifier {
|
||||||
|
final List<TorrentInfo> _torrents = [];
|
||||||
|
Timer? _progressTimer;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
DownloadsProvider() {
|
||||||
|
_startProgressUpdates();
|
||||||
|
loadDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startProgressUpdates() {
|
||||||
|
_progressTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||||
|
_updateProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadDownloads() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final progressList = await TorrentPlatformService.getAllDownloads();
|
||||||
|
|
||||||
|
_downloads = progressList.map((progress) {
|
||||||
|
// Try to find existing download to preserve metadata
|
||||||
|
final existing = _downloads.where((d) => d.infoHash == progress.infoHash).firstOrNull;
|
||||||
|
|
||||||
|
return ActiveDownload(
|
||||||
|
infoHash: progress.infoHash,
|
||||||
|
name: existing?.name ?? 'Unnamed Torrent',
|
||||||
|
progress: progress,
|
||||||
|
startTime: existing?.startTime ?? DateTime.now(),
|
||||||
|
selectedFiles: existing?.selectedFiles ?? [],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateProgress() async {
|
||||||
|
if (_downloads.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<ActiveDownload> updatedDownloads = [];
|
||||||
|
|
||||||
|
for (final download in _downloads) {
|
||||||
|
final progress = await TorrentPlatformService.getDownloadProgress(download.infoHash);
|
||||||
|
if (progress != null) {
|
||||||
|
updatedDownloads.add(download.copyWith(progress: progress));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloads = updatedDownloads;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure for progress updates
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to update progress: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> pauseDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.pauseDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
await _updateProgress();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> resumeDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.resumeDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
await _updateProgress();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> cancelDownload(String infoHash) async {
|
||||||
|
try {
|
||||||
|
final success = await TorrentPlatformService.cancelDownload(infoHash);
|
||||||
|
if (success) {
|
||||||
|
_downloads.removeWhere((d) => d.infoHash == infoHash);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDownload({
|
||||||
|
required String infoHash,
|
||||||
|
required String name,
|
||||||
|
required List<String> selectedFiles,
|
||||||
|
}) {
|
||||||
|
final download = ActiveDownload(
|
||||||
|
infoHash: infoHash,
|
||||||
|
name: name,
|
||||||
|
progress: DownloadProgress(
|
||||||
|
infoHash: infoHash,
|
||||||
|
progress: 0.0,
|
||||||
|
downloadRate: 0,
|
||||||
|
uploadRate: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
numPeers: 0,
|
||||||
|
state: 'starting',
|
||||||
|
),
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
selectedFiles: selectedFiles,
|
||||||
|
);
|
||||||
|
|
||||||
|
_downloads.add(download);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveDownload? getDownload(String infoHash) {
|
||||||
|
try {
|
||||||
|
return _downloads.where((d) => d.infoHash == infoHash).first;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatSpeed(int bytesPerSecond) {
|
||||||
|
return '${formatFileSize(bytesPerSecond)}/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatDuration(Duration duration) {
|
||||||
|
if (duration.inDays > 0) {
|
||||||
|
return '${duration.inDays}d ${duration.inHours % 24}h';
|
||||||
|
}
|
||||||
|
if (duration.inHours > 0) {
|
||||||
|
return '${duration.inHours}h ${duration.inMinutes % 60}m';
|
||||||
|
}
|
||||||
|
if (duration.inMinutes > 0) {
|
||||||
|
return '${duration.inMinutes}m ${duration.inSeconds % 60}s';
|
||||||
|
}
|
||||||
|
return '${duration.inSeconds}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ListExtension<T> on List<T> {
|
||||||
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
|
}
|
||||||
535
lib/presentation/screens/downloads/download_detail_screen.dart
Normal file
535
lib/presentation/screens/downloads/download_detail_screen.dart
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../player/native_video_player_screen.dart';
|
||||||
|
import '../player/webview_player_screen.dart';
|
||||||
|
|
||||||
|
class DownloadDetailScreen extends StatefulWidget {
|
||||||
|
final ActiveDownload download;
|
||||||
|
|
||||||
|
const DownloadDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.download,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadDetailScreen> createState() => _DownloadDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadDetailScreenState extends State<DownloadDetailScreen> {
|
||||||
|
List<DownloadedFile> _files = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDownloadedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDownloadedFiles() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get downloads directory
|
||||||
|
final downloadsDir = await getApplicationDocumentsDirectory();
|
||||||
|
final torrentDir = Directory('${downloadsDir.path}/torrents/${widget.download.infoHash}');
|
||||||
|
|
||||||
|
if (await torrentDir.exists()) {
|
||||||
|
final files = await _scanDirectory(torrentDir);
|
||||||
|
setState(() {
|
||||||
|
_files = files;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_files = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_files = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DownloadedFile>> _scanDirectory(Directory directory) async {
|
||||||
|
final List<DownloadedFile> files = [];
|
||||||
|
|
||||||
|
await for (final entity in directory.list(recursive: true)) {
|
||||||
|
if (entity is File) {
|
||||||
|
final stat = await entity.stat();
|
||||||
|
final fileName = entity.path.split('/').last;
|
||||||
|
final extension = fileName.split('.').last.toLowerCase();
|
||||||
|
|
||||||
|
files.add(DownloadedFile(
|
||||||
|
name: fileName,
|
||||||
|
path: entity.path,
|
||||||
|
size: stat.size,
|
||||||
|
isVideo: _isVideoFile(extension),
|
||||||
|
isAudio: _isAudioFile(extension),
|
||||||
|
extension: extension,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files..sort((a, b) => a.name.compareTo(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVideoFile(String extension) {
|
||||||
|
const videoExtensions = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
|
||||||
|
return videoExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAudioFile(String extension) {
|
||||||
|
const audioExtensions = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'];
|
||||||
|
return audioExtensions.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.download.name),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _loadDownloadedFiles,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_buildProgressSection(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: _buildFilesSection(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressSection() {
|
||||||
|
final progress = widget.download.progress;
|
||||||
|
final isCompleted = progress.progress >= 1.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Прогресс загрузки',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(progress.progress * 100).toStringAsFixed(1)}% - ${progress.state}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCompleted
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isCompleted ? 'Завершено' : 'Загружается',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.progress,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
isCompleted ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildProgressStat('Скорость', '${_formatSpeed(progress.downloadRate)}'),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
_buildProgressStat('Сиды', '${progress.numSeeds}'),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
_buildProgressStat('Пиры', '${progress.numPeers}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressStat(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilesSection() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Сканирование файлов...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_files.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.folder_open,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Файлы не найдены',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Возможно, загрузка еще не завершена',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Файлы (${_files.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: _files.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _files[index];
|
||||||
|
return _buildFileItem(file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileItem(DownloadedFile file) {
|
||||||
|
return Card(
|
||||||
|
elevation: 1,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: file.isVideo || file.isAudio ? () => _openFile(file) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildFileIcon(file),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatFileSize(file.size),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) => _handleFileAction(value, file),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
if (file.isVideo || file.isAudio) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_native',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Нативный плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (file.isVideo) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_vibix',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Vibix плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_alloha',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Alloha плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
],
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileIcon(DownloadedFile file) {
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
if (file.isVideo) {
|
||||||
|
icon = Icons.movie;
|
||||||
|
color = Colors.blue;
|
||||||
|
} else if (file.isAudio) {
|
||||||
|
icon = Icons.music_note;
|
||||||
|
color = Colors.orange;
|
||||||
|
} else {
|
||||||
|
icon = Icons.insert_drive_file;
|
||||||
|
color = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFile(DownloadedFile file) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => NativeVideoPlayerScreen(
|
||||||
|
filePath: file.path,
|
||||||
|
title: file.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFileAction(String action, DownloadedFile file) {
|
||||||
|
switch (action) {
|
||||||
|
case 'play_native':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => NativeVideoPlayerScreen(
|
||||||
|
filePath: file.path,
|
||||||
|
title: file.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'play_vibix':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
url: 'https://vibix.org/player',
|
||||||
|
title: file.name,
|
||||||
|
playerType: 'vibix',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'play_alloha':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
url: 'https://alloha.org/player',
|
||||||
|
title: file.name,
|
||||||
|
playerType: 'alloha',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
_showDeleteDialog(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteDialog(DownloadedFile file) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить файл'),
|
||||||
|
content: Text('Вы уверены, что хотите удалить файл "${file.name}"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _deleteFile(file);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteFile(DownloadedFile file) async {
|
||||||
|
try {
|
||||||
|
final fileToDelete = File(file.path);
|
||||||
|
if (await fileToDelete.exists()) {
|
||||||
|
await fileToDelete.delete();
|
||||||
|
_loadDownloadedFiles(); // Refresh the list
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Файл "${file.name}" удален'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Ошибка удаления файла: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSpeed(int bytesPerSecond) {
|
||||||
|
return '${_formatFileSize(bytesPerSecond)}/s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadedFile {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final int size;
|
||||||
|
final bool isVideo;
|
||||||
|
final bool isAudio;
|
||||||
|
final String extension;
|
||||||
|
|
||||||
|
DownloadedFile({
|
||||||
|
required this.name,
|
||||||
|
required this.path,
|
||||||
|
required this.size,
|
||||||
|
required this.isVideo,
|
||||||
|
required this.isAudio,
|
||||||
|
required this.extension,
|
||||||
|
});
|
||||||
|
}
|
||||||
444
lib/presentation/screens/downloads/downloads_screen.dart
Normal file
444
lib/presentation/screens/downloads/downloads_screen.dart
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../../../data/models/torrent_info.dart';
|
||||||
|
import 'torrent_detail_screen.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DownloadsScreen extends StatefulWidget {
|
||||||
|
const DownloadsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<DownloadsProvider>().refreshDownloads();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Загрузки'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<DownloadsProvider>().refreshDownloads();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Consumer<DownloadsProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка загрузки',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
provider.error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
provider.refreshDownloads();
|
||||||
|
},
|
||||||
|
child: const Text('Попробовать снова'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.torrents.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.download_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Нет активных загрузок',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Загруженные торренты будут отображаться здесь',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await provider.refreshDownloads();
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: provider.torrents.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final torrent = provider.torrents[index];
|
||||||
|
return TorrentListItem(
|
||||||
|
torrent: torrent,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => TorrentDetailScreen(
|
||||||
|
infoHash: torrent.infoHash,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onMenuPressed: (action) {
|
||||||
|
_handleTorrentAction(action, torrent);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTorrentAction(TorrentAction action, TorrentInfo torrent) {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case TorrentAction.pause:
|
||||||
|
provider.pauseTorrent(torrent.infoHash);
|
||||||
|
break;
|
||||||
|
case TorrentAction.resume:
|
||||||
|
provider.resumeTorrent(torrent.infoHash);
|
||||||
|
break;
|
||||||
|
case TorrentAction.remove:
|
||||||
|
_showRemoveConfirmation(torrent);
|
||||||
|
break;
|
||||||
|
case TorrentAction.openFolder:
|
||||||
|
_openFolder(torrent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRemoveConfirmation(TorrentInfo torrent) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Удалить торрент'),
|
||||||
|
content: Text(
|
||||||
|
'Вы уверены, что хотите удалить "${torrent.name}"?\n\nФайлы будут удалены с устройства.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.read<DownloadsProvider>().removeTorrent(torrent.infoHash);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFolder(TorrentInfo torrent) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Папка: ${torrent.savePath}'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Копировать',
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Copy path to clipboard
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TorrentAction { pause, resume, remove, openFolder }
|
||||||
|
|
||||||
|
class TorrentListItem extends StatelessWidget {
|
||||||
|
final TorrentInfo torrent;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Function(TorrentAction) onMenuPressed;
|
||||||
|
|
||||||
|
const TorrentListItem({
|
||||||
|
super.key,
|
||||||
|
required this.torrent,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onMenuPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
torrent.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PopupMenuButton<TorrentAction>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: onMenuPressed,
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (torrent.isPaused)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.resume,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Возобновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.pause,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.pause),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Приостановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.openFolder,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.folder_open),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Открыть папку'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: TorrentAction.remove,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildProgressBar(context),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildStatusChip(),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
torrent.formattedTotalSize,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (torrent.isDownloading || torrent.isSeeding) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.download,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
torrent.formattedDownloadSpeed,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Icon(
|
||||||
|
Icons.upload,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.blue.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
torrent.formattedUploadSpeed,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'S: ${torrent.numSeeds} P: ${torrent.numPeers}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressBar(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Прогресс',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${(torrent.progress * 100).toStringAsFixed(1)}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: torrent.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
torrent.isCompleted
|
||||||
|
? Colors.green.shade600
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChip() {
|
||||||
|
Color color;
|
||||||
|
IconData icon;
|
||||||
|
String text;
|
||||||
|
|
||||||
|
if (torrent.isCompleted) {
|
||||||
|
color = Colors.green;
|
||||||
|
icon = Icons.check_circle;
|
||||||
|
text = 'Завершен';
|
||||||
|
} else if (torrent.isDownloading) {
|
||||||
|
color = Colors.blue;
|
||||||
|
icon = Icons.download;
|
||||||
|
text = 'Загружается';
|
||||||
|
} else if (torrent.isPaused) {
|
||||||
|
color = Colors.orange;
|
||||||
|
icon = Icons.pause;
|
||||||
|
text = 'Приостановлен';
|
||||||
|
} else if (torrent.isSeeding) {
|
||||||
|
color = Colors.purple;
|
||||||
|
icon = Icons.upload;
|
||||||
|
text = 'Раздача';
|
||||||
|
} else {
|
||||||
|
color = Colors.grey;
|
||||||
|
icon = Icons.help_outline;
|
||||||
|
text = torrent.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
574
lib/presentation/screens/downloads/torrent_detail_screen.dart
Normal file
574
lib/presentation/screens/downloads/torrent_detail_screen.dart
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../../../data/models/torrent_info.dart';
|
||||||
|
import '../player/video_player_screen.dart';
|
||||||
|
import '../player/webview_player_screen.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class TorrentDetailScreen extends StatefulWidget {
|
||||||
|
final String infoHash;
|
||||||
|
|
||||||
|
const TorrentDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.infoHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TorrentDetailScreen> createState() => _TorrentDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TorrentDetailScreenState extends State<TorrentDetailScreen> {
|
||||||
|
TorrentInfo? torrentInfo;
|
||||||
|
bool isLoading = true;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTorrentInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTorrentInfo() async {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
final info = await provider.getTorrentInfo(widget.infoHash);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
torrentInfo = info;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
error = e.toString();
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(torrentInfo?.name ?? 'Торрент'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
foregroundColor: Theme.of(context).textTheme.titleLarge?.color,
|
||||||
|
actions: [
|
||||||
|
if (torrentInfo != null)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) => _handleAction(value),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (torrentInfo!.isPaused)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'resume',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Возобновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'pause',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.pause),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Приостановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'refresh',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.refresh),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Обновить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'remove',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка загрузки',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadTorrentInfo,
|
||||||
|
child: const Text('Попробовать снова'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (torrentInfo == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('Торрент не найден'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTorrentInfo(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildFilesSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTorrentInfo() {
|
||||||
|
final torrent = torrentInfo!;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Информация о торренте',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildInfoRow('Название', torrent.name),
|
||||||
|
_buildInfoRow('Размер', torrent.formattedTotalSize),
|
||||||
|
_buildInfoRow('Прогресс', '${(torrent.progress * 100).toStringAsFixed(1)}%'),
|
||||||
|
_buildInfoRow('Статус', _getStatusText(torrent)),
|
||||||
|
_buildInfoRow('Путь сохранения', torrent.savePath),
|
||||||
|
if (torrent.isDownloading || torrent.isSeeding) ...[
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Скорость загрузки', torrent.formattedDownloadSpeed),
|
||||||
|
_buildInfoRow('Скорость раздачи', torrent.formattedUploadSpeed),
|
||||||
|
_buildInfoRow('Сиды', '${torrent.numSeeds}'),
|
||||||
|
_buildInfoRow('Пиры', '${torrent.numPeers}'),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: torrent.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
torrent.isCompleted
|
||||||
|
? Colors.green.shade600
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusText(TorrentInfo torrent) {
|
||||||
|
if (torrent.isCompleted) return 'Завершен';
|
||||||
|
if (torrent.isDownloading) return 'Загружается';
|
||||||
|
if (torrent.isPaused) return 'Приостановлен';
|
||||||
|
if (torrent.isSeeding) return 'Раздача';
|
||||||
|
return torrent.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilesSection() {
|
||||||
|
final torrent = torrentInfo!;
|
||||||
|
final videoFiles = torrent.videoFiles;
|
||||||
|
final otherFiles = torrent.files.where((file) => !videoFiles.contains(file)).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Файлы',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Video files section
|
||||||
|
if (videoFiles.isNotEmpty) ...[
|
||||||
|
_buildFileTypeSection('Видео файлы', videoFiles, Icons.play_circle_fill),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Other files section
|
||||||
|
if (otherFiles.isNotEmpty) ...[
|
||||||
|
_buildFileTypeSection('Другие файлы', otherFiles, Icons.insert_drive_file),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileTypeSection(String title, List<TorrentFileInfo> files, IconData icon) {
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${files.length} файлов',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: files.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = files[index];
|
||||||
|
return _buildFileItem(file, icon == Icons.play_circle_fill);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileItem(TorrentFileInfo file, bool isVideo) {
|
||||||
|
final fileName = file.path.split('/').last;
|
||||||
|
final fileExtension = fileName.split('.').last.toUpperCase();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: isVideo
|
||||||
|
? Colors.red.shade100
|
||||||
|
: Colors.blue.shade100,
|
||||||
|
child: Text(
|
||||||
|
fileExtension,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isVideo
|
||||||
|
? Colors.red.shade700
|
||||||
|
: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
fileName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatFileSize(file.size),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (file.progress > 0 && file.progress < 1.0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: file.progress,
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) => _handleFileAction(value, file),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
if (isVideo && file.progress >= 0.1) ...[
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_native',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.play_arrow),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Нативный плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_vibix',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Vibix плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play_alloha',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Alloha плеер'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
],
|
||||||
|
PopupMenuItem(
|
||||||
|
value: file.priority == FilePriority.DONT_DOWNLOAD ? 'download' : 'stop_download',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(file.priority == FilePriority.DONT_DOWNLOAD ? Icons.download : Icons.stop),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(file.priority == FilePriority.DONT_DOWNLOAD ? 'Скачать' : 'Остановить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'priority_${file.priority == FilePriority.HIGH ? 'normal' : 'high'}',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(file.priority == FilePriority.HIGH ? Icons.flag : Icons.flag_outlined),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(file.priority == FilePriority.HIGH ? 'Обычный приоритет' : 'Высокий приоритет'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: isVideo && file.progress >= 0.1
|
||||||
|
? () => _playVideo(file, 'native')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAction(String action) async {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'pause':
|
||||||
|
await provider.pauseTorrent(widget.infoHash);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
await provider.resumeTorrent(widget.infoHash);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'refresh':
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
_showRemoveConfirmation();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFileAction(String action, TorrentFileInfo file) async {
|
||||||
|
final provider = context.read<DownloadsProvider>();
|
||||||
|
|
||||||
|
if (action.startsWith('play_')) {
|
||||||
|
final playerType = action.replaceFirst('play_', '');
|
||||||
|
_playVideo(file, playerType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.startsWith('priority_')) {
|
||||||
|
final priority = action.replaceFirst('priority_', '');
|
||||||
|
final newPriority = priority == 'high' ? FilePriority.HIGH : FilePriority.NORMAL;
|
||||||
|
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, newPriority);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'download':
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.NORMAL);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
case 'stop_download':
|
||||||
|
final fileIndex = torrentInfo!.files.indexOf(file);
|
||||||
|
await provider.setFilePriority(widget.infoHash, fileIndex, FilePriority.DONT_DOWNLOAD);
|
||||||
|
_loadTorrentInfo();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playVideo(TorrentFileInfo file, String playerType) {
|
||||||
|
final filePath = '${torrentInfo!.savePath}/${file.path}';
|
||||||
|
|
||||||
|
switch (playerType) {
|
||||||
|
case 'native':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => VideoPlayerScreen(
|
||||||
|
filePath: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'vibix':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
playerType: WebPlayerType.vibix,
|
||||||
|
videoUrl: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'alloha':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => WebViewPlayerScreen(
|
||||||
|
playerType: WebPlayerType.alloha,
|
||||||
|
videoUrl: filePath,
|
||||||
|
title: file.path.split('/').last,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRemoveConfirmation() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Удалить торрент'),
|
||||||
|
content: Text(
|
||||||
|
'Вы уверены, что хотите удалить "${torrentInfo!.name}"?\n\nФайлы будут удалены с устройства.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.read<DownloadsProvider>().removeTorrent(widget.infoHash);
|
||||||
|
Navigator.of(context).pop(); // Возвращаемся к списку загрузок
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +1,290 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'dart:io';
|
||||||
import 'package:neomovies_mobile/utils/device_utils.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:neomovies_mobile/presentation/widgets/player/web_player_widget.dart';
|
|
||||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
class VideoPlayerScreen extends StatefulWidget {
|
class VideoPlayerScreen extends StatefulWidget {
|
||||||
final String mediaId; // Теперь это IMDB ID
|
final String filePath;
|
||||||
final String mediaType; // 'movie' or 'tv'
|
final String title;
|
||||||
final String? title;
|
|
||||||
final String? subtitle;
|
|
||||||
final String? posterUrl;
|
|
||||||
|
|
||||||
const VideoPlayerScreen({
|
const VideoPlayerScreen({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.mediaId,
|
required this.filePath,
|
||||||
required this.mediaType,
|
required this.title,
|
||||||
this.title,
|
});
|
||||||
this.subtitle,
|
|
||||||
this.posterUrl,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||||
VideoSource _selectedSource = VideoSource.defaultSources.first;
|
VideoPlayerController? _controller;
|
||||||
|
bool _isControlsVisible = true;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupPlayerEnvironment();
|
_initializePlayer();
|
||||||
}
|
|
||||||
|
|
||||||
void _setupPlayerEnvironment() {
|
|
||||||
// Keep screen awake during video playback
|
|
||||||
WakelockPlus.enable();
|
|
||||||
|
|
||||||
// Set landscape orientation
|
|
||||||
SystemChrome.setPreferredOrientations([
|
|
||||||
DeviceOrientation.landscapeLeft,
|
|
||||||
DeviceOrientation.landscapeRight,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Hide system UI for immersive experience
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_restoreSystemSettings();
|
_controller?.dispose();
|
||||||
|
_setOrientation(false);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _restoreSystemSettings() {
|
Future<void> _initializePlayer() async {
|
||||||
// Restore system UI and allow screen to sleep
|
try {
|
||||||
WakelockPlus.disable();
|
final file = File(widget.filePath);
|
||||||
|
if (!await file.exists()) {
|
||||||
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
|
setState(() {
|
||||||
if (DeviceUtils.isLargeScreen(context)) {
|
_error = 'Файл не найден: ${widget.filePath}';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = VideoPlayerController.file(file);
|
||||||
|
|
||||||
|
await _controller!.initialize();
|
||||||
|
|
||||||
|
_controller!.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto play
|
||||||
|
_controller!.play();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Ошибка инициализации плеера: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePlayPause() {
|
||||||
|
if (_controller!.value.isPlaying) {
|
||||||
|
_controller!.pause();
|
||||||
|
} else {
|
||||||
|
_controller!.play();
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFullscreen() {
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = !_isFullscreen;
|
||||||
|
});
|
||||||
|
_setOrientation(_isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOrientation(bool isFullscreen) {
|
||||||
|
if (isFullscreen) {
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
DeviceOrientation.portraitDown,
|
|
||||||
DeviceOrientation.landscapeLeft,
|
DeviceOrientation.landscapeLeft,
|
||||||
DeviceOrientation.landscapeRight,
|
DeviceOrientation.landscapeRight,
|
||||||
]);
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
} else {
|
} else {
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
]);
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleControls() {
|
||||||
|
setState(() {
|
||||||
|
_isControlsVisible = !_isControlsVisible;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_isControlsVisible) {
|
||||||
|
// Hide controls after 3 seconds
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (mounted && _controller!.value.isPlaying) {
|
||||||
|
setState(() {
|
||||||
|
_isControlsVisible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||||
|
final minutes = twoDigits(duration.inMinutes.remainder(60));
|
||||||
|
final seconds = twoDigits(duration.inSeconds.remainder(60));
|
||||||
|
final hours = duration.inHours;
|
||||||
|
|
||||||
// Restore system UI
|
if (hours > 0) {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
return '$hours:$minutes:$seconds';
|
||||||
|
} else {
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return WillPopScope(
|
|
||||||
onWillPop: () async {
|
|
||||||
_restoreSystemSettings();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
child: _VideoPlayerScreenContent(
|
|
||||||
title: widget.title,
|
|
||||||
mediaId: widget.mediaId,
|
|
||||||
selectedSource: _selectedSource,
|
|
||||||
onSourceChanged: (source) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_selectedSource = source;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPlayerScreenContent extends StatelessWidget {
|
|
||||||
final String mediaId; // IMDB ID
|
|
||||||
final String? title;
|
|
||||||
final VideoSource selectedSource;
|
|
||||||
final ValueChanged<VideoSource> onSourceChanged;
|
|
||||||
|
|
||||||
const _VideoPlayerScreenContent({
|
|
||||||
Key? key,
|
|
||||||
required this.mediaId,
|
|
||||||
this.title,
|
|
||||||
required this.selectedSource,
|
|
||||||
required this.onSourceChanged,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
appBar: _isFullscreen ? null : AppBar(
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Source selector header
|
const Icon(
|
||||||
Container(
|
Icons.error_outline,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
size: 64,
|
||||||
color: Colors.black87,
|
color: Colors.white,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
IconButton(
|
const Text(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
'Ошибка воспроизведения',
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
style: TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
const SizedBox(width: 8),
|
fontSize: 18,
|
||||||
const Text(
|
fontWeight: FontWeight.w600,
|
||||||
'Источник: ',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
|
||||||
),
|
|
||||||
_buildSourceSelector(),
|
|
||||||
const Spacer(),
|
|
||||||
if (title != null)
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Text(
|
|
||||||
title!,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
// Video player
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Назад'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller == null || !_controller!.value.isInitialized) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _toggleControls,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Video player
|
||||||
|
Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: _controller!.value.aspectRatio,
|
||||||
|
child: VideoPlayer(_controller!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Controls overlay
|
||||||
|
if (_isControlsVisible)
|
||||||
|
_buildControlsOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlsOverlay() {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.7),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.3, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Top bar
|
||||||
|
if (_isFullscreen) _buildTopBar(),
|
||||||
|
|
||||||
|
// Center play/pause
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: _buildCenterControls(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
_buildBottomControls(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopBar() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: WebPlayerWidget(
|
child: Text(
|
||||||
key: ValueKey(selectedSource.id),
|
widget.title,
|
||||||
mediaId: mediaId,
|
style: const TextStyle(
|
||||||
source: selectedSource,
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -166,24 +293,137 @@ class _VideoPlayerScreenContent extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSourceSelector() {
|
Widget _buildCenterControls() {
|
||||||
return DropdownButton<VideoSource>(
|
return Row(
|
||||||
value: selectedSource,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
dropdownColor: Colors.black87,
|
children: [
|
||||||
style: const TextStyle(color: Colors.white),
|
IconButton(
|
||||||
underline: Container(),
|
iconSize: 48,
|
||||||
items: VideoSource.defaultSources
|
icon: Icon(
|
||||||
.where((source) => source.isActive)
|
Icons.replay_10,
|
||||||
.map((source) => DropdownMenuItem<VideoSource>(
|
color: Colors.white.withOpacity(0.8),
|
||||||
value: source,
|
),
|
||||||
child: Text(source.name),
|
onPressed: () {
|
||||||
))
|
final newPosition = _controller!.value.position - const Duration(seconds: 10);
|
||||||
.toList(),
|
_controller!.seekTo(newPosition < Duration.zero ? Duration.zero : newPosition);
|
||||||
onChanged: (VideoSource? newSource) {
|
},
|
||||||
if (newSource != null) {
|
),
|
||||||
onSourceChanged(newSource);
|
const SizedBox(width: 32),
|
||||||
}
|
Container(
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
iconSize: 64,
|
||||||
|
icon: Icon(
|
||||||
|
_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _togglePlayPause,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
IconButton(
|
||||||
|
iconSize: 48,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.forward_10,
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
final newPosition = _controller!.value.position + const Duration(seconds: 10);
|
||||||
|
final maxDuration = _controller!.value.duration;
|
||||||
|
_controller!.seekTo(newPosition > maxDuration ? maxDuration : newPosition);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildBottomControls() {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Progress bar
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDuration(_controller!.value.position),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: VideoProgressIndicator(
|
||||||
|
_controller!,
|
||||||
|
allowScrubbing: true,
|
||||||
|
colors: VideoProgressColors(
|
||||||
|
playedColor: Theme.of(context).primaryColor,
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.3),
|
||||||
|
bufferedColor: Colors.white.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_formatDuration(_controller!.value.duration),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_controller!.value.volume == 0 ? Icons.volume_off : Icons.volume_up,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (_controller!.value.volume == 0) {
|
||||||
|
_controller!.setVolume(1.0);
|
||||||
|
} else {
|
||||||
|
_controller!.setVolume(0.0);
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
PopupMenuButton<double>(
|
||||||
|
icon: const Icon(Icons.speed, color: Colors.white),
|
||||||
|
onSelected: (speed) {
|
||||||
|
_controller!.setPlaybackSpeed(speed);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: 0.5, child: Text('0.5x')),
|
||||||
|
const PopupMenuItem(value: 0.75, child: Text('0.75x')),
|
||||||
|
const PopupMenuItem(value: 1.0, child: Text('1.0x')),
|
||||||
|
const PopupMenuItem(value: 1.25, child: Text('1.25x')),
|
||||||
|
const PopupMenuItem(value: 1.5, child: Text('1.5x')),
|
||||||
|
const PopupMenuItem(value: 2.0, child: Text('2.0x')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
440
lib/presentation/screens/player/webview_player_screen.dart
Normal file
440
lib/presentation/screens/player/webview_player_screen.dart
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
|
||||||
|
enum WebPlayerType { vibix, alloha }
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class WebViewPlayerScreen extends StatefulWidget {
|
||||||
|
final WebPlayerType playerType;
|
||||||
|
final String videoUrl;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const WebViewPlayerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.playerType,
|
||||||
|
required this.videoUrl,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebViewPlayerScreen> createState() => _WebViewPlayerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebViewPlayerScreenState extends State<WebViewPlayerScreen> {
|
||||||
|
late WebViewController _controller;
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeWebView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_setOrientation(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeWebView() {
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onProgress: (int progress) {
|
||||||
|
// Update loading progress
|
||||||
|
},
|
||||||
|
onPageStarted: (String url) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPageFinished: (String url) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onWebResourceError: (WebResourceError error) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Ошибка загрузки: ${error.description}';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_loadPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadPlayer() {
|
||||||
|
final playerUrl = _getPlayerUrl();
|
||||||
|
_controller.loadRequest(Uri.parse(playerUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPlayerUrl() {
|
||||||
|
switch (widget.playerType) {
|
||||||
|
case WebPlayerType.vibix:
|
||||||
|
return _getVibixUrl();
|
||||||
|
case WebPlayerType.alloha:
|
||||||
|
return _getAllohaUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getVibixUrl() {
|
||||||
|
// Vibix player URL with embedded video
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
|
||||||
|
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getAllohaUrl() {
|
||||||
|
// Alloha player URL with embedded video
|
||||||
|
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
|
||||||
|
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFullscreen() {
|
||||||
|
setState(() {
|
||||||
|
_isFullscreen = !_isFullscreen;
|
||||||
|
});
|
||||||
|
_setOrientation(_isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOrientation(bool isFullscreen) {
|
||||||
|
if (isFullscreen) {
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPlayerName() {
|
||||||
|
switch (widget.playerType) {
|
||||||
|
case WebPlayerType.vibix:
|
||||||
|
return 'Vibix';
|
||||||
|
case WebPlayerType.alloha:
|
||||||
|
return 'Alloha';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: _isFullscreen ? null : AppBar(
|
||||||
|
title: Text(
|
||||||
|
'${_getPlayerName()} - ${widget.title}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
iconTheme: const IconThemeData(color: Colors.white),
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
onSelected: (value) => _handleMenuAction(value),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'reload',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.refresh),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Перезагрузить'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'share',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.share),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Поделиться'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_error != null) {
|
||||||
|
return _buildErrorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// WebView
|
||||||
|
WebViewWidget(controller: _controller),
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if (_isLoading)
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Загрузка плеера...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fullscreen toggle for when player is loaded
|
||||||
|
if (!_isLoading && !_isFullscreen)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.7),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.fullscreen, color: Colors.white),
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorState() {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка загрузки плеера',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
_loadPlayer();
|
||||||
|
},
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.white),
|
||||||
|
),
|
||||||
|
child: const Text('Назад'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPlayerInfo(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlayerInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade900.withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Информация о плеере',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildInfoRow('Плеер', _getPlayerName()),
|
||||||
|
_buildInfoRow('Файл', widget.title),
|
||||||
|
_buildInfoRow('URL', widget.videoUrl),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMenuAction(String action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'reload':
|
||||||
|
_loadPlayer();
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
_shareVideo();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareVideo() {
|
||||||
|
// TODO: Implement sharing functionality
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Поделиться: ${widget.title}'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper widget for creating custom HTML player if needed
|
||||||
|
class CustomPlayerWidget extends StatelessWidget {
|
||||||
|
final String videoUrl;
|
||||||
|
final String title;
|
||||||
|
final WebPlayerType playerType;
|
||||||
|
|
||||||
|
const CustomPlayerWidget({
|
||||||
|
super.key,
|
||||||
|
required this.videoUrl,
|
||||||
|
required this.title,
|
||||||
|
required this.playerType,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.play_circle_filled,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Плеер: ${playerType == WebPlayerType.vibix ? 'Vibix' : 'Alloha'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Нажмите для воспроизведения',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,11 +58,16 @@ dependencies:
|
|||||||
# Utils
|
# Utils
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
auto_route: ^8.3.0
|
||||||
|
# File operations and path management
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
permission_handler: ^11.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
freezed: ^2.4.5
|
freezed: ^2.4.5
|
||||||
json_serializable: ^6.7.1
|
json_serializable: ^6.7.1
|
||||||
hive_generator: ^2.0.1
|
hive_generator: ^2.0.1
|
||||||
|
auto_route_generator: ^8.3.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user