mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 03:18:49 +05:00
Compare commits
12 Commits
fix-api-au
...
7828b378d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7828b378d7 | |||
|
|
23943f5206 | ||
|
|
78c321b0f0 | ||
|
|
9b84492db4 | ||
|
|
8179b39aa4 | ||
| 66032b681c | |||
|
|
016ef05fee | ||
|
|
13e7c0d0b0 | ||
|
|
3e1a9768d8 | ||
|
|
39f311d02e | ||
| 3081510f9e | |||
|
|
4596df1a2e |
1
.github/workflows/gitlab-mirror.yml
vendored
1
.github/workflows/gitlab-mirror.yml
vendored
@@ -29,7 +29,6 @@ jobs:
|
||||
- name: Check for differences with GitLab
|
||||
id: diffcheck
|
||||
run: |
|
||||
# Если нет ветки main на GitLab, пушим всегда
|
||||
if ! git rev-parse gitlab/main >/dev/null 2>&1; then
|
||||
echo "has_diff=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -183,6 +183,19 @@ jobs:
|
||||
See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }})
|
||||
EOF
|
||||
|
||||
- name: Delete previous release if exists
|
||||
run: |
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.version.outputs.version }}" \
|
||||
| jq -r '.id // empty')
|
||||
if [ ! -z "$RELEASE_ID" ]; then
|
||||
echo "Deleting previous release $RELEASE_ID"
|
||||
curl -X DELETE -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -191,6 +204,7 @@ jobs:
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') }}
|
||||
make_latest: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
|
||||
files: |
|
||||
./apks/app-arm64-v8a-release.apk
|
||||
./apks/app-armeabi-v7a-release.apk
|
||||
|
||||
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
- dev
|
||||
- 'feature/**'
|
||||
- 'torrent-engine-downloads'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -22,15 +23,19 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.35.5'
|
||||
flutter-version: '3.19.6'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Run code generation
|
||||
run: |
|
||||
dart run build_runner build --delete-conflicting-outputs || true
|
||||
|
||||
- name: Run Flutter Analyze
|
||||
run: flutter analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
- name: Check formatting
|
||||
run: dart format --set-exit-if-changed .
|
||||
@@ -55,6 +60,13 @@ jobs:
|
||||
- name: Run tests
|
||||
run: flutter test --coverage
|
||||
|
||||
- name: Run Integration tests
|
||||
run: flutter test test/integration/ --reporter=expanded
|
||||
env:
|
||||
# Mark that we're running in CI
|
||||
CI: true
|
||||
GITHUB_ACTIONS: true
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
FLUTTER_VERSION: "stable"
|
||||
# Optimize for RAM usage
|
||||
FLUTTER_BUILD_FLAGS: "--split-debug-info=./debug-symbols --obfuscate --dart-define=dart.vm.profile=false"
|
||||
PUB_CACHE: "${CI_PROJECT_DIR}/.pub-cache"
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .pub-cache/
|
||||
|
||||
# Test stage - runs first to catch issues early
|
||||
test:dart:
|
||||
stage: test
|
||||
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||
script:
|
||||
- flutter --version
|
||||
- flutter pub get
|
||||
- flutter analyze --fatal-warnings
|
||||
- flutter test --coverage
|
||||
- flutter build web --release --dart-define=dart.vm.profile=false
|
||||
artifacts:
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage/cobertura.xml
|
||||
paths:
|
||||
- coverage/
|
||||
- build/web/
|
||||
expire_in: 7 days
|
||||
rules:
|
||||
- if: $CI_MERGE_REQUEST_IID
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
build:apk:arm64:
|
||||
stage: build
|
||||
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||
script:
|
||||
- flutter pub get
|
||||
- flutter build apk --release --target-platform android-arm64 --split-per-abi
|
||||
- mkdir -p debug-symbols
|
||||
- flutter build apk --release --target-platform android-arm64 --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||
artifacts:
|
||||
paths:
|
||||
- build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
@@ -26,7 +59,8 @@ build:apk:arm:
|
||||
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||
script:
|
||||
- flutter pub get
|
||||
- flutter build apk --release --target-platform android-arm --split-per-abi
|
||||
- mkdir -p debug-symbols
|
||||
- flutter build apk --release --target-platform android-arm --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||
artifacts:
|
||||
paths:
|
||||
- build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||
@@ -42,7 +76,8 @@ build:apk:x64:
|
||||
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
||||
script:
|
||||
- flutter pub get
|
||||
- flutter build apk --release --target-platform android-x64 --split-per-abi
|
||||
- mkdir -p debug-symbols
|
||||
- flutter build apk --release --target-platform android-x64 --split-per-abi ${FLUTTER_BUILD_FLAGS}
|
||||
artifacts:
|
||||
paths:
|
||||
- build/app/outputs/flutter-apk/app-x86_64-release.apk
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
130
lib/data/services/player_embed_service.dart
Normal file
130
lib/data/services/player_embed_service.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service for getting player embed URLs from NeoMovies API server
|
||||
class PlayerEmbedService {
|
||||
static const String _baseUrl = 'https://neomovies.site'; // Replace with actual base URL
|
||||
|
||||
/// Get Vibix player embed URL from server
|
||||
static Future<String> getVibixEmbedUrl({
|
||||
required String videoUrl,
|
||||
required String title,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/player/vibix/embed'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'videoUrl': videoUrl,
|
||||
'title': title,
|
||||
'imdbId': imdbId,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'autoplay': true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['embedUrl'] as String;
|
||||
} else {
|
||||
throw Exception('Failed to get Vibix embed URL: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to direct URL if server is unavailable
|
||||
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||
final encodedTitle = Uri.encodeComponent(title);
|
||||
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Alloha player embed URL from server
|
||||
static Future<String> getAllohaEmbedUrl({
|
||||
required String videoUrl,
|
||||
required String title,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/player/alloha/embed'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'videoUrl': videoUrl,
|
||||
'title': title,
|
||||
'imdbId': imdbId,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'autoplay': true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['embedUrl'] as String;
|
||||
} else {
|
||||
throw Exception('Failed to get Alloha embed URL: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to direct URL if server is unavailable
|
||||
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||
final encodedTitle = Uri.encodeComponent(title);
|
||||
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get player configuration from server
|
||||
static Future<Map<String, dynamic>?> getPlayerConfig({
|
||||
required String playerType,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/player/$playerType/config').replace(
|
||||
queryParameters: {
|
||||
if (imdbId != null) 'imdbId': imdbId,
|
||||
if (season != null) 'season': season,
|
||||
if (episode != null) 'episode': episode,
|
||||
},
|
||||
),
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server player API is available
|
||||
static Future<bool> isServerApiAvailable() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/player/health'),
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/torrent_info.dart';
|
||||
|
||||
/// Data classes for torrent metadata (matching Kotlin side)
|
||||
|
||||
@@ -340,106 +341,89 @@ class DownloadProgress {
|
||||
class TorrentPlatformService {
|
||||
static const MethodChannel _channel = MethodChannel('com.neo.neomovies_mobile/torrent');
|
||||
|
||||
/// Получить базовую информацию из magnet-ссылки
|
||||
static Future<MagnetBasicInfo> parseMagnetBasicInfo(String magnetUri) async {
|
||||
try {
|
||||
final String result = await _channel.invokeMethod('parseMagnetBasicInfo', {
|
||||
'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,
|
||||
/// Add torrent from magnet URI and start downloading
|
||||
static Future<String> addTorrent({
|
||||
required String magnetUri,
|
||||
String? savePath,
|
||||
}) async {
|
||||
try {
|
||||
final String infoHash = await _channel.invokeMethod('startDownload', {
|
||||
'magnetLink': magnetLink,
|
||||
'selectedFiles': selectedFiles,
|
||||
'downloadPath': downloadPath,
|
||||
final String infoHash = await _channel.invokeMethod('addTorrent', {
|
||||
'magnetUri': magnetUri,
|
||||
'savePath': savePath ?? '/storage/emulated/0/Download/NeoMovies',
|
||||
});
|
||||
|
||||
return infoHash;
|
||||
} 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
|
||||
static Future<DownloadProgress?> getDownloadProgress(String infoHash) async {
|
||||
try {
|
||||
final String? result = await _channel.invokeMethod('getDownloadProgress', {
|
||||
'infoHash': infoHash,
|
||||
});
|
||||
final torrentInfo = await getTorrent(infoHash);
|
||||
if (torrentInfo == null) return null;
|
||||
|
||||
if (result == null) return null;
|
||||
|
||||
final Map<String, dynamic> json = jsonDecode(result);
|
||||
return DownloadProgress.fromJson(json);
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'NOT_FOUND') return null;
|
||||
throw Exception('Failed to get download progress: ${e.message}');
|
||||
return DownloadProgress(
|
||||
infoHash: torrentInfo.infoHash,
|
||||
progress: torrentInfo.progress,
|
||||
downloadRate: torrentInfo.downloadSpeed,
|
||||
uploadRate: torrentInfo.uploadSpeed,
|
||||
numSeeds: torrentInfo.numSeeds,
|
||||
numPeers: torrentInfo.numPeers,
|
||||
state: torrentInfo.state,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to parse download progress: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause download
|
||||
static Future<bool> pauseDownload(String infoHash) async {
|
||||
try {
|
||||
final bool result = await _channel.invokeMethod('pauseDownload', {
|
||||
final bool result = await _channel.invokeMethod('pauseTorrent', {
|
||||
'infoHash': infoHash,
|
||||
});
|
||||
|
||||
@@ -452,7 +436,7 @@ class TorrentPlatformService {
|
||||
/// Resume download
|
||||
static Future<bool> resumeDownload(String infoHash) async {
|
||||
try {
|
||||
final bool result = await _channel.invokeMethod('resumeDownload', {
|
||||
final bool result = await _channel.invokeMethod('resumeTorrent', {
|
||||
'infoHash': infoHash,
|
||||
});
|
||||
|
||||
@@ -465,8 +449,9 @@ class TorrentPlatformService {
|
||||
/// Cancel and remove download
|
||||
static Future<bool> cancelDownload(String infoHash) async {
|
||||
try {
|
||||
final bool result = await _channel.invokeMethod('cancelDownload', {
|
||||
final bool result = await _channel.invokeMethod('removeTorrent', {
|
||||
'infoHash': infoHash,
|
||||
'deleteFiles': true,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -475,19 +460,137 @@ class TorrentPlatformService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all active downloads
|
||||
static Future<List<DownloadProgress>> getAllDownloads() async {
|
||||
/// Set file priority
|
||||
static Future<bool> setFilePriority(String infoHash, int fileIndex, FilePriority priority) async {
|
||||
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 jsonList
|
||||
.map((json) => DownloadProgress.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
return result;
|
||||
} 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) {
|
||||
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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import 'package:chewie/chewie.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/video_quality.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/audio_track.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/subtitle.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/subtitle.dart' as local_subtitle;
|
||||
import 'package:neomovies_mobile/data/models/player/player_settings.dart';
|
||||
|
||||
class PlayerProvider with ChangeNotifier {
|
||||
@@ -37,13 +37,13 @@ class PlayerProvider with ChangeNotifier {
|
||||
List<VideoSource> _sources = [];
|
||||
List<VideoQuality> _qualities = [];
|
||||
List<AudioTrack> _audioTracks = [];
|
||||
List<Subtitle> _subtitles = [];
|
||||
List<local_subtitle.Subtitle> _subtitles = [];
|
||||
|
||||
// Selected options
|
||||
VideoSource? _selectedSource;
|
||||
VideoQuality? _selectedQuality;
|
||||
AudioTrack? _selectedAudioTrack;
|
||||
Subtitle? _selectedSubtitle;
|
||||
local_subtitle.Subtitle? _selectedSubtitle;
|
||||
|
||||
// Playback state
|
||||
double _volume = 1.0;
|
||||
@@ -67,11 +67,11 @@ class PlayerProvider with ChangeNotifier {
|
||||
List<VideoSource> get sources => _sources;
|
||||
List<VideoQuality> get qualities => _qualities;
|
||||
List<AudioTrack> get audioTracks => _audioTracks;
|
||||
List<Subtitle> get subtitles => _subtitles;
|
||||
List<local_subtitle.Subtitle> get subtitles => _subtitles;
|
||||
VideoSource? get selectedSource => _selectedSource;
|
||||
VideoQuality? get selectedQuality => _selectedQuality;
|
||||
AudioTrack? get selectedAudioTrack => _selectedAudioTrack;
|
||||
Subtitle? get selectedSubtitle => _selectedSubtitle;
|
||||
local_subtitle.Subtitle? get selectedSubtitle => _selectedSubtitle;
|
||||
double get volume => _volume;
|
||||
bool get isMuted => _isMuted;
|
||||
double get playbackSpeed => _playbackSpeed;
|
||||
@@ -94,7 +94,7 @@ class PlayerProvider with ChangeNotifier {
|
||||
List<VideoSource>? sources,
|
||||
List<VideoQuality>? qualities,
|
||||
List<AudioTrack>? audioTracks,
|
||||
List<Subtitle>? subtitles,
|
||||
List<local_subtitle.Subtitle>? subtitles,
|
||||
}) async {
|
||||
_mediaId = mediaId;
|
||||
_mediaType = mediaType;
|
||||
@@ -305,7 +305,7 @@ class PlayerProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Change subtitle
|
||||
void setSubtitle(Subtitle subtitle) {
|
||||
void setSubtitle(local_subtitle.Subtitle subtitle) {
|
||||
if (_selectedSubtitle == subtitle) return;
|
||||
|
||||
_selectedSubtitle = subtitle;
|
||||
|
||||
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('Удалить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,13 +63,11 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
mediaId: imdbId,
|
||||
mediaType: widget.mediaType,
|
||||
title: title,
|
||||
),
|
||||
// TODO: Implement proper player navigation with mediaId
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Player feature will be implemented. Media ID: $imdbId'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,163 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:neomovies_mobile/utils/device_utils.dart';
|
||||
import 'package:neomovies_mobile/presentation/widgets/player/web_player_widget.dart';
|
||||
import 'package:neomovies_mobile/data/models/player/video_source.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'dart:io';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
|
||||
@RoutePage()
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final String mediaId; // Теперь это IMDB ID
|
||||
final String mediaType; // 'movie' or 'tv'
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
final String? posterUrl;
|
||||
final String filePath;
|
||||
final String title;
|
||||
|
||||
const VideoPlayerScreen({
|
||||
Key? key,
|
||||
required this.mediaId,
|
||||
required this.mediaType,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.posterUrl,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||
}
|
||||
|
||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
VideoSource _selectedSource = VideoSource.defaultSources.first;
|
||||
VideoPlayerController? _controller;
|
||||
bool _isControlsVisible = true;
|
||||
bool _isFullscreen = false;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupPlayerEnvironment();
|
||||
}
|
||||
|
||||
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);
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restoreSystemSettings();
|
||||
_controller?.dispose();
|
||||
_setOrientation(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _restoreSystemSettings() {
|
||||
// Restore system UI and allow screen to sleep
|
||||
WakelockPlus.disable();
|
||||
Future<void> _initializePlayer() async {
|
||||
try {
|
||||
final file = File(widget.filePath);
|
||||
if (!await file.exists()) {
|
||||
setState(() {
|
||||
_error = 'Файл не найден: ${widget.filePath}';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore orientation: phones back to portrait, tablets/TV keep free rotation
|
||||
if (DeviceUtils.isLargeScreen(context)) {
|
||||
_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([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
} else {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
}
|
||||
|
||||
// Restore system UI
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
|
||||
@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;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerScreenContent extends StatelessWidget {
|
||||
final String mediaId; // IMDB ID
|
||||
final String? title;
|
||||
final VideoSource selectedSource;
|
||||
final ValueChanged<VideoSource> onSourceChanged;
|
||||
void _toggleControls() {
|
||||
setState(() {
|
||||
_isControlsVisible = !_isControlsVisible;
|
||||
});
|
||||
|
||||
const _VideoPlayerScreenContent({
|
||||
Key? key,
|
||||
required this.mediaId,
|
||||
this.title,
|
||||
required this.selectedSource,
|
||||
required this.onSourceChanged,
|
||||
}) : super(key: key);
|
||||
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;
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours:$minutes:$seconds';
|
||||
} else {
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Ошибка воспроизведения',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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: [
|
||||
// Source selector header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Colors.black87,
|
||||
// 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(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Источник: ',
|
||||
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),
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Video player
|
||||
Expanded(
|
||||
child: WebPlayerWidget(
|
||||
key: ValueKey(selectedSource.id),
|
||||
mediaId: mediaId,
|
||||
source: selectedSource,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -166,24 +293,137 @@ class _VideoPlayerScreenContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceSelector() {
|
||||
return DropdownButton<VideoSource>(
|
||||
value: selectedSource,
|
||||
dropdownColor: Colors.black87,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
underline: Container(),
|
||||
items: VideoSource.defaultSources
|
||||
.where((source) => source.isActive)
|
||||
.map((source) => DropdownMenuItem<VideoSource>(
|
||||
value: source,
|
||||
child: Text(source.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (VideoSource? newSource) {
|
||||
if (newSource != null) {
|
||||
onSourceChanged(newSource);
|
||||
}
|
||||
Widget _buildCenterControls() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 48,
|
||||
icon: Icon(
|
||||
Icons.replay_10,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
onPressed: () {
|
||||
final newPosition = _controller!.value.position - const Duration(seconds: 10);
|
||||
_controller!.seekTo(newPosition < Duration.zero ? Duration.zero : newPosition);
|
||||
},
|
||||
),
|
||||
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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
469
lib/presentation/screens/player/webview_player_screen.dart
Normal file
469
lib/presentation/screens/player/webview_player_screen.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import '../../../data/services/player_embed_service.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() async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final playerUrl = await _getPlayerUrl();
|
||||
_controller.loadRequest(Uri.parse(playerUrl));
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Ошибка получения URL плеера: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getPlayerUrl() async {
|
||||
switch (widget.playerType) {
|
||||
case WebPlayerType.vibix:
|
||||
return await _getVibixUrl();
|
||||
case WebPlayerType.alloha:
|
||||
return await _getAllohaUrl();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getVibixUrl() async {
|
||||
try {
|
||||
// Try to get embed URL from API server first
|
||||
return await PlayerEmbedService.getVibixEmbedUrl(
|
||||
videoUrl: widget.videoUrl,
|
||||
title: widget.title,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to direct URL if server is unavailable
|
||||
final encodedVideoUrl = Uri.encodeComponent(widget.videoUrl);
|
||||
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=${Uri.encodeComponent(widget.title)}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getAllohaUrl() async {
|
||||
try {
|
||||
// Try to get embed URL from API server first
|
||||
return await PlayerEmbedService.getAllohaEmbedUrl(
|
||||
videoUrl: widget.videoUrl,
|
||||
title: widget.title,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fallback to direct URL if server is unavailable
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
218
pubspec.lock
218
pubspec.lock
@@ -41,6 +41,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
auto_route:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: auto_route
|
||||
sha256: a9001a90539ca3effc168f7e1029a5885c7326b9032c09ac895e303c1d137704
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.0"
|
||||
auto_route_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: auto_route_generator
|
||||
sha256: a21d7a936c917488653c972f62d884d8adcf8c5d37acc7cd24da33cf784546c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
bloc:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -117,18 +133,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27"
|
||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.10.1"
|
||||
version: "8.12.0"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
version: "3.4.0"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,10 +157,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.3.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -165,10 +181,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: chewie
|
||||
sha256: "19b93a1e60e4ba640a792208a6543f1c7d5b124d011ce0199e2f18802199d984"
|
||||
sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
version: "1.13.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -189,10 +205,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.1"
|
||||
version: "4.11.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -249,6 +265,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -436,10 +468,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
|
||||
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
version: "6.3.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -484,10 +516,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.5.0"
|
||||
http_mock_adapter:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: http_mock_adapter
|
||||
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -584,6 +624,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -652,18 +700,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.0"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -673,7 +721,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -684,18 +732,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
||||
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.17"
|
||||
version: "2.2.18"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -720,14 +768,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.0.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -748,10 +844,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -764,10 +860,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -804,10 +900,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
||||
sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
version: "2.4.14"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -913,18 +1009,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b"
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1025,18 +1121,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
||||
sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.16"
|
||||
version: "6.3.23"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
||||
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "6.3.4"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1049,10 +1145,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "3.2.3"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1137,42 +1233,42 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.0"
|
||||
version: "15.0.2"
|
||||
wakelock_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678
|
||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.4.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207
|
||||
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
version: "1.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
|
||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.1.4"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "0.5.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1201,26 +1297,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: f6e6afef6e234801da77170f7a1847ded8450778caf2fe13979d140484be3678
|
||||
sha256: "21507ea5a326ceeba4d29dea19e37d92d53d9959cfc746317b9f9f7a57418d87"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.0"
|
||||
version: "4.10.3"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147
|
||||
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
version: "2.14.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3
|
||||
sha256: fea63576b3b7e02b2df8b78ba92b48ed66caec2bb041e9a0b1cbd586d5d80bfd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.22.0"
|
||||
version: "3.23.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1241,10 +1337,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1254,5 +1350,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.1 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -58,16 +58,23 @@ dependencies:
|
||||
# Utils
|
||||
equatable: ^2.0.5
|
||||
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:
|
||||
freezed: ^2.4.5
|
||||
json_serializable: ^6.7.1
|
||||
hive_generator: ^2.0.1
|
||||
auto_route_generator: ^8.1.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
build_runner: ^2.4.13
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
# HTTP mocking for testing
|
||||
http_mock_adapter: ^0.6.1
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
83
test/integration/ci_environment_test.dart
Normal file
83
test/integration/ci_environment_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('CI Environment Tests', () {
|
||||
test('should detect GitHub Actions environment', () {
|
||||
final isGitHubActions = Platform.environment['GITHUB_ACTIONS'] == 'true';
|
||||
final isCI = Platform.environment['CI'] == 'true';
|
||||
final runnerOS = Platform.environment['RUNNER_OS'];
|
||||
|
||||
print('Environment Variables:');
|
||||
print(' GITHUB_ACTIONS: ${Platform.environment['GITHUB_ACTIONS']}');
|
||||
print(' CI: ${Platform.environment['CI']}');
|
||||
print(' RUNNER_OS: $runnerOS');
|
||||
print(' Platform: ${Platform.operatingSystem}');
|
||||
|
||||
if (isGitHubActions || isCI) {
|
||||
print('Running in CI/GitHub Actions environment');
|
||||
expect(isCI, isTrue, reason: 'CI environment variable should be set');
|
||||
|
||||
if (isGitHubActions) {
|
||||
expect(runnerOS, isNotNull, reason: 'RUNNER_OS should be set in GitHub Actions');
|
||||
print(' GitHub Actions Runner OS: $runnerOS');
|
||||
}
|
||||
} else {
|
||||
print('Running in local development environment');
|
||||
}
|
||||
|
||||
// Test should always pass regardless of environment
|
||||
expect(Platform.operatingSystem, isNotEmpty);
|
||||
});
|
||||
|
||||
test('should have correct Dart/Flutter environment in CI', () {
|
||||
final dartVersion = Platform.version;
|
||||
print('Dart version: $dartVersion');
|
||||
|
||||
// In CI, we should have Dart available
|
||||
expect(dartVersion, isNotEmpty);
|
||||
expect(dartVersion, contains('Dart'));
|
||||
|
||||
// Check if running in CI and validate expected environment
|
||||
final isCI = Platform.environment['CI'] == 'true';
|
||||
if (isCI) {
|
||||
print('Dart environment validated in CI');
|
||||
|
||||
// CI should have these basic characteristics
|
||||
expect(Platform.operatingSystem, anyOf('linux', 'macos', 'windows'));
|
||||
|
||||
// GitHub Actions typically runs on Linux
|
||||
final runnerOS = Platform.environment['RUNNER_OS'];
|
||||
if (runnerOS == 'Linux') {
|
||||
expect(Platform.operatingSystem, 'linux');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle network connectivity gracefully', () async {
|
||||
// Simple network test that won't fail in restricted environments
|
||||
try {
|
||||
// Test with a reliable endpoint
|
||||
final socket = await Socket.connect('8.8.8.8', 53, timeout: const Duration(seconds: 5));
|
||||
socket.destroy();
|
||||
print('Network connectivity available');
|
||||
} catch (e) {
|
||||
print('Limited network connectivity: $e');
|
||||
// Don't fail the test - some CI environments have restricted network
|
||||
}
|
||||
|
||||
// Test should always pass
|
||||
expect(true, isTrue);
|
||||
});
|
||||
|
||||
test('should validate test infrastructure', () {
|
||||
// Basic test framework validation
|
||||
expect(testWidgets, isNotNull, reason: 'Flutter test framework should be available');
|
||||
expect(setUp, isNotNull, reason: 'Test setup functions should be available');
|
||||
expect(tearDown, isNotNull, reason: 'Test teardown functions should be available');
|
||||
|
||||
print('Test infrastructure validated');
|
||||
});
|
||||
});
|
||||
}
|
||||
196
test/models/torrent_info_test.dart
Normal file
196
test/models/torrent_info_test.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:neomovies_mobile/data/models/torrent_info.dart';
|
||||
|
||||
void main() {
|
||||
group('TorrentInfo', () {
|
||||
test('fromAndroidJson creates valid TorrentInfo', () {
|
||||
final json = {
|
||||
'infoHash': 'test_hash',
|
||||
'name': 'Test Torrent',
|
||||
'totalSize': 1024000000,
|
||||
'progress': 0.5,
|
||||
'downloadSpeed': 1024000,
|
||||
'uploadSpeed': 512000,
|
||||
'numSeeds': 10,
|
||||
'numPeers': 5,
|
||||
'state': 'DOWNLOADING',
|
||||
'savePath': '/test/path',
|
||||
'files': [
|
||||
{
|
||||
'path': 'test.mp4',
|
||||
'size': 1024000000,
|
||||
'priority': 4,
|
||||
'progress': 0.5,
|
||||
}
|
||||
],
|
||||
'pieceLength': 16384,
|
||||
'numPieces': 62500,
|
||||
'addedTime': 1640995200000,
|
||||
};
|
||||
|
||||
final torrentInfo = TorrentInfo.fromAndroidJson(json);
|
||||
|
||||
expect(torrentInfo.infoHash, equals('test_hash'));
|
||||
expect(torrentInfo.name, equals('Test Torrent'));
|
||||
expect(torrentInfo.totalSize, equals(1024000000));
|
||||
expect(torrentInfo.progress, equals(0.5));
|
||||
expect(torrentInfo.downloadSpeed, equals(1024000));
|
||||
expect(torrentInfo.uploadSpeed, equals(512000));
|
||||
expect(torrentInfo.numSeeds, equals(10));
|
||||
expect(torrentInfo.numPeers, equals(5));
|
||||
expect(torrentInfo.state, equals('DOWNLOADING'));
|
||||
expect(torrentInfo.savePath, equals('/test/path'));
|
||||
expect(torrentInfo.files.length, equals(1));
|
||||
expect(torrentInfo.files.first.path, equals('test.mp4'));
|
||||
expect(torrentInfo.files.first.size, equals(1024000000));
|
||||
expect(torrentInfo.files.first.priority, equals(FilePriority.NORMAL));
|
||||
});
|
||||
|
||||
test('isDownloading returns true for DOWNLOADING state', () {
|
||||
final torrent = TorrentInfo(
|
||||
infoHash: 'test',
|
||||
name: 'test',
|
||||
totalSize: 100,
|
||||
progress: 0.5,
|
||||
downloadSpeed: 1000,
|
||||
uploadSpeed: 500,
|
||||
numSeeds: 5,
|
||||
numPeers: 3,
|
||||
state: 'DOWNLOADING',
|
||||
savePath: '/test',
|
||||
files: [],
|
||||
);
|
||||
|
||||
expect(torrent.isDownloading, isTrue);
|
||||
expect(torrent.isPaused, isFalse);
|
||||
expect(torrent.isSeeding, isFalse);
|
||||
expect(torrent.isCompleted, isFalse);
|
||||
});
|
||||
|
||||
test('isCompleted returns true for progress >= 1.0', () {
|
||||
final torrent = TorrentInfo(
|
||||
infoHash: 'test',
|
||||
name: 'test',
|
||||
totalSize: 100,
|
||||
progress: 1.0,
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 500,
|
||||
numSeeds: 5,
|
||||
numPeers: 3,
|
||||
state: 'SEEDING',
|
||||
savePath: '/test',
|
||||
files: [],
|
||||
);
|
||||
|
||||
expect(torrent.isCompleted, isTrue);
|
||||
expect(torrent.isSeeding, isTrue);
|
||||
});
|
||||
|
||||
test('videoFiles returns only video files', () {
|
||||
final torrent = TorrentInfo(
|
||||
infoHash: 'test',
|
||||
name: 'test',
|
||||
totalSize: 100,
|
||||
progress: 1.0,
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 0,
|
||||
numSeeds: 0,
|
||||
numPeers: 0,
|
||||
state: 'COMPLETED',
|
||||
savePath: '/test',
|
||||
files: [
|
||||
TorrentFileInfo(
|
||||
path: 'movie.mp4',
|
||||
size: 1000000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
TorrentFileInfo(
|
||||
path: 'subtitle.srt',
|
||||
size: 10000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
TorrentFileInfo(
|
||||
path: 'episode.mkv',
|
||||
size: 2000000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final videoFiles = torrent.videoFiles;
|
||||
expect(videoFiles.length, equals(2));
|
||||
expect(videoFiles.any((file) => file.path == 'movie.mp4'), isTrue);
|
||||
expect(videoFiles.any((file) => file.path == 'episode.mkv'), isTrue);
|
||||
expect(videoFiles.any((file) => file.path == 'subtitle.srt'), isFalse);
|
||||
});
|
||||
|
||||
test('mainVideoFile returns largest video file', () {
|
||||
final torrent = TorrentInfo(
|
||||
infoHash: 'test',
|
||||
name: 'test',
|
||||
totalSize: 100,
|
||||
progress: 1.0,
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 0,
|
||||
numSeeds: 0,
|
||||
numPeers: 0,
|
||||
state: 'COMPLETED',
|
||||
savePath: '/test',
|
||||
files: [
|
||||
TorrentFileInfo(
|
||||
path: 'small.mp4',
|
||||
size: 1000000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
TorrentFileInfo(
|
||||
path: 'large.mkv',
|
||||
size: 5000000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
TorrentFileInfo(
|
||||
path: 'medium.avi',
|
||||
size: 3000000,
|
||||
priority: FilePriority.NORMAL,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final mainFile = torrent.mainVideoFile;
|
||||
expect(mainFile?.path, equals('large.mkv'));
|
||||
expect(mainFile?.size, equals(5000000));
|
||||
});
|
||||
|
||||
test('formattedTotalSize formats bytes correctly', () {
|
||||
final torrent = TorrentInfo(
|
||||
infoHash: 'test',
|
||||
name: 'test',
|
||||
totalSize: 1073741824, // 1 GB
|
||||
progress: 0.0,
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 0,
|
||||
numSeeds: 0,
|
||||
numPeers: 0,
|
||||
state: 'PAUSED',
|
||||
savePath: '/test',
|
||||
files: [],
|
||||
);
|
||||
|
||||
expect(torrent.formattedTotalSize, equals('1.0GB'));
|
||||
});
|
||||
});
|
||||
|
||||
group('FilePriority', () {
|
||||
test('fromValue returns correct priority', () {
|
||||
expect(FilePriority.fromValue(0), equals(FilePriority.DONT_DOWNLOAD));
|
||||
expect(FilePriority.fromValue(4), equals(FilePriority.NORMAL));
|
||||
expect(FilePriority.fromValue(7), equals(FilePriority.HIGH));
|
||||
expect(FilePriority.fromValue(999), equals(FilePriority.NORMAL)); // Default
|
||||
});
|
||||
|
||||
test('comparison operators work correctly', () {
|
||||
expect(FilePriority.HIGH > FilePriority.NORMAL, isTrue);
|
||||
expect(FilePriority.NORMAL > FilePriority.DONT_DOWNLOAD, isTrue);
|
||||
expect(FilePriority.DONT_DOWNLOAD < FilePriority.HIGH, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
41
test/providers/downloads_provider_test.dart
Normal file
41
test/providers/downloads_provider_test.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:neomovies_mobile/presentation/providers/downloads_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('DownloadsProvider', () {
|
||||
late DownloadsProvider provider;
|
||||
|
||||
setUp(() {
|
||||
provider = DownloadsProvider();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('initial state is correct', () {
|
||||
expect(provider.torrents, isEmpty);
|
||||
expect(provider.isLoading, isFalse);
|
||||
expect(provider.error, isNull);
|
||||
});
|
||||
|
||||
test('formatSpeed formats bytes correctly', () {
|
||||
expect(provider.formatSpeed(1024), equals('1.0KB/s'));
|
||||
expect(provider.formatSpeed(1048576), equals('1.0MB/s'));
|
||||
expect(provider.formatSpeed(512), equals('512B/s'));
|
||||
expect(provider.formatSpeed(2048000), equals('2.0MB/s'));
|
||||
});
|
||||
|
||||
test('formatDuration formats duration correctly', () {
|
||||
expect(provider.formatDuration(Duration(seconds: 30)), equals('30с'));
|
||||
expect(provider.formatDuration(Duration(minutes: 2, seconds: 30)), equals('2м 30с'));
|
||||
expect(provider.formatDuration(Duration(hours: 1, minutes: 30, seconds: 45)), equals('1ч 30м 45с'));
|
||||
expect(provider.formatDuration(Duration(hours: 2)), equals('2ч 0м 0с'));
|
||||
});
|
||||
|
||||
test('provider implements ChangeNotifier', () {
|
||||
expect(provider, isA<ChangeNotifier>());
|
||||
});
|
||||
});
|
||||
}
|
||||
381
test/services/player_embed_service_test.dart
Normal file
381
test/services/player_embed_service_test.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:neomovies_mobile/data/services/player_embed_service.dart';
|
||||
|
||||
void main() {
|
||||
group('PlayerEmbedService Tests', () {
|
||||
group('Vibix Player', () {
|
||||
test('should get embed URL from API server successfully', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/player/vibix/embed') {
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['videoUrl'], 'http://example.com/video.mp4');
|
||||
expect(body['title'], 'Test Movie');
|
||||
expect(body['autoplay'], true);
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'embedUrl': 'https://vibix.me/embed/custom?src=encoded&autoplay=1',
|
||||
'success': true,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
// Mock the http client (in real implementation, you'd inject this)
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test Movie',
|
||||
);
|
||||
|
||||
expect(embedUrl, 'https://vibix.me/embed/custom?src=encoded&autoplay=1');
|
||||
});
|
||||
|
||||
test('should fallback to direct URL when server fails', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Server Error', 500);
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test Movie',
|
||||
);
|
||||
|
||||
expect(embedUrl, contains('vibix.me/embed'));
|
||||
expect(embedUrl, contains('src=http%3A//example.com/video.mp4'));
|
||||
expect(embedUrl, contains('title=Test%20Movie'));
|
||||
});
|
||||
|
||||
test('should handle network timeout gracefully', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
throw const SocketException('Connection timeout');
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test Movie',
|
||||
);
|
||||
|
||||
// Should fallback to direct URL
|
||||
expect(embedUrl, contains('vibix.me/embed'));
|
||||
});
|
||||
|
||||
test('should include optional parameters in API request', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/player/vibix/embed') {
|
||||
final body = jsonDecode(request.body);
|
||||
expect(body['imdbId'], 'tt1234567');
|
||||
expect(body['season'], '1');
|
||||
expect(body['episode'], '5');
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({'embedUrl': 'https://vibix.me/embed/tv'}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test TV Show',
|
||||
imdbId: 'tt1234567',
|
||||
season: '1',
|
||||
episode: '5',
|
||||
);
|
||||
|
||||
expect(embedUrl, 'https://vibix.me/embed/tv');
|
||||
});
|
||||
});
|
||||
|
||||
group('Alloha Player', () {
|
||||
test('should get embed URL from API server successfully', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/player/alloha/embed') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'embedUrl': 'https://alloha.tv/embed/custom?src=encoded',
|
||||
'success': true,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetAllohaEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test Movie',
|
||||
);
|
||||
|
||||
expect(embedUrl, 'https://alloha.tv/embed/custom?src=encoded');
|
||||
});
|
||||
|
||||
test('should fallback to direct URL when server fails', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Server Error', 500);
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetAllohaEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Test Movie',
|
||||
);
|
||||
|
||||
expect(embedUrl, contains('alloha.tv/embed'));
|
||||
expect(embedUrl, contains('src=http%3A//example.com/video.mp4'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Player Configuration', () {
|
||||
test('should get player config from server', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/player/vibix/config') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'playerOptions': {
|
||||
'autoplay': true,
|
||||
'controls': true,
|
||||
'volume': 0.8,
|
||||
},
|
||||
'theme': 'dark',
|
||||
'language': 'ru',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final config = await _testGetPlayerConfig(
|
||||
client: mockClient,
|
||||
playerType: 'vibix',
|
||||
imdbId: 'tt1234567',
|
||||
);
|
||||
|
||||
expect(config, isNotNull);
|
||||
expect(config!['playerOptions']['autoplay'], true);
|
||||
expect(config['theme'], 'dark');
|
||||
});
|
||||
|
||||
test('should return null when config not available', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final config = await _testGetPlayerConfig(
|
||||
client: mockClient,
|
||||
playerType: 'nonexistent',
|
||||
);
|
||||
|
||||
expect(config, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Server Health Check', () {
|
||||
test('should return true when server is available', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/player/health') {
|
||||
return http.Response(
|
||||
jsonEncode({'status': 'ok', 'version': '1.0.0'}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||
expect(isAvailable, true);
|
||||
});
|
||||
|
||||
test('should return false when server is unavailable', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Server Error', 500);
|
||||
});
|
||||
|
||||
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||
expect(isAvailable, false);
|
||||
});
|
||||
|
||||
test('should return false on network timeout', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
throw const SocketException('Connection timeout');
|
||||
});
|
||||
|
||||
final isAvailable = await _testIsServerApiAvailable(mockClient);
|
||||
expect(isAvailable, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('URL Encoding', () {
|
||||
test('should properly encode special characters in video URL', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Server Error', 500); // Force fallback
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/path with spaces/movie&test.mp4',
|
||||
title: 'Movie Title (2023)',
|
||||
);
|
||||
|
||||
expect(embedUrl, contains('path%20with%20spaces'));
|
||||
expect(embedUrl, contains('movie%26test.mp4'));
|
||||
expect(embedUrl, contains('Movie%20Title%20%282023%29'));
|
||||
});
|
||||
|
||||
test('should handle non-ASCII characters in title', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('Server Error', 500); // Force fallback
|
||||
});
|
||||
|
||||
final embedUrl = await _testGetVibixEmbedUrl(
|
||||
client: mockClient,
|
||||
videoUrl: 'http://example.com/video.mp4',
|
||||
title: 'Тест Фильм Россия',
|
||||
);
|
||||
|
||||
expect(embedUrl, contains('title=%D0%A2%D0%B5%D1%81%D1%82'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions to test with mocked http client
|
||||
// Note: In a real implementation, you would inject the http client
|
||||
|
||||
Future<String> _testGetVibixEmbedUrl({
|
||||
required http.Client client,
|
||||
required String videoUrl,
|
||||
required String title,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
// This simulates the PlayerEmbedService.getVibixEmbedUrl behavior
|
||||
// In real implementation, you'd need dependency injection for the http client
|
||||
try {
|
||||
final response = await client.post(
|
||||
Uri.parse('https://neomovies.site/api/player/vibix/embed'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'videoUrl': videoUrl,
|
||||
'title': title,
|
||||
'imdbId': imdbId,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'autoplay': true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['embedUrl'] as String;
|
||||
} else {
|
||||
throw Exception('Failed to get Vibix embed URL: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to direct URL
|
||||
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||
final encodedTitle = Uri.encodeComponent(title);
|
||||
return 'https://vibix.me/embed/?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _testGetAllohaEmbedUrl({
|
||||
required http.Client client,
|
||||
required String videoUrl,
|
||||
required String title,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await client.post(
|
||||
Uri.parse('https://neomovies.site/api/player/alloha/embed'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'videoUrl': videoUrl,
|
||||
'title': title,
|
||||
'imdbId': imdbId,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'autoplay': true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['embedUrl'] as String;
|
||||
} else {
|
||||
throw Exception('Failed to get Alloha embed URL: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to direct URL
|
||||
final encodedVideoUrl = Uri.encodeComponent(videoUrl);
|
||||
final encodedTitle = Uri.encodeComponent(title);
|
||||
return 'https://alloha.tv/embed?src=$encodedVideoUrl&autoplay=1&title=$encodedTitle';
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _testGetPlayerConfig({
|
||||
required http.Client client,
|
||||
required String playerType,
|
||||
String? imdbId,
|
||||
String? season,
|
||||
String? episode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await client.get(
|
||||
Uri.parse('https://neomovies.site/api/player/$playerType/config').replace(
|
||||
queryParameters: {
|
||||
if (imdbId != null) 'imdbId': imdbId,
|
||||
if (season != null) 'season': season,
|
||||
if (episode != null) 'episode': episode,
|
||||
},
|
||||
),
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _testIsServerApiAvailable(http.Client client) async {
|
||||
try {
|
||||
final response = await client.get(
|
||||
Uri.parse('https://neomovies.site/api/player/health'),
|
||||
headers: {'Accept': 'application/json'},
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
111
test/services/torrent_platform_service_simple_test.dart
Normal file
111
test/services/torrent_platform_service_simple_test.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:neomovies_mobile/data/services/torrent_platform_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('TorrentPlatformService Tests', () {
|
||||
late List<MethodCall> methodCalls;
|
||||
|
||||
setUp(() {
|
||||
methodCalls = [];
|
||||
|
||||
// Mock the platform channel
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.neo.neomovies_mobile/torrent'),
|
||||
(MethodCall methodCall) async {
|
||||
methodCalls.add(methodCall);
|
||||
return _handleMethodCall(methodCall);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.neo.neomovies_mobile/torrent'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('addTorrent should call platform method with correct parameters', () async {
|
||||
const magnetUri = 'magnet:?xt=urn:btih:test123&dn=test.movie.mkv';
|
||||
const savePath = '/storage/emulated/0/Download/Torrents';
|
||||
|
||||
final result = await TorrentPlatformService.addTorrent(
|
||||
magnetUri: magnetUri,
|
||||
savePath: savePath
|
||||
);
|
||||
|
||||
expect(methodCalls.length, 1);
|
||||
expect(methodCalls.first.method, 'addTorrent');
|
||||
expect(methodCalls.first.arguments, {
|
||||
'magnetUri': magnetUri,
|
||||
'savePath': savePath,
|
||||
});
|
||||
expect(result, 'test-hash-123');
|
||||
});
|
||||
|
||||
test('parseMagnetBasicInfo should parse magnet URI correctly', () async {
|
||||
const magnetUri = 'magnet:?xt=urn:btih:abc123&dn=test%20movie&tr=http%3A//tracker.example.com%3A8080/announce';
|
||||
|
||||
final result = await TorrentPlatformService.parseMagnetBasicInfo(magnetUri);
|
||||
|
||||
expect(result.name, 'test movie');
|
||||
expect(result.infoHash, 'abc123');
|
||||
expect(result.trackers.length, 1);
|
||||
expect(result.trackers.first, 'http://tracker.example.com:8080/announce');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Mock method call handler for torrent platform channel
|
||||
dynamic _handleMethodCall(MethodCall methodCall) {
|
||||
switch (methodCall.method) {
|
||||
case 'addTorrent':
|
||||
return 'test-hash-123';
|
||||
|
||||
case 'getTorrents':
|
||||
return jsonEncode([
|
||||
{
|
||||
'infoHash': 'test-hash-123',
|
||||
'progress': 0.5,
|
||||
'downloadSpeed': 1024000,
|
||||
'uploadSpeed': 512000,
|
||||
'numSeeds': 5,
|
||||
'numPeers': 10,
|
||||
'state': 'downloading',
|
||||
}
|
||||
]);
|
||||
|
||||
case 'getTorrent':
|
||||
return jsonEncode({
|
||||
'name': 'Test Movie',
|
||||
'infoHash': 'test-hash-123',
|
||||
'totalSize': 1073741824,
|
||||
'files': [
|
||||
{
|
||||
'path': 'Test Movie.mkv',
|
||||
'size': 1073741824,
|
||||
'priority': 4,
|
||||
}
|
||||
],
|
||||
'downloadedSize': 536870912,
|
||||
'downloadSpeed': 1024000,
|
||||
'uploadSpeed': 512000,
|
||||
'state': 'downloading',
|
||||
'progress': 0.5,
|
||||
'numSeeds': 5,
|
||||
'numPeers': 10,
|
||||
'addedTime': DateTime.now().millisecondsSinceEpoch,
|
||||
'ratio': 0.8,
|
||||
});
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
79
test/widget_test.dart
Normal file
79
test/widget_test.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||
// Build a minimal app for testing
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('NeoMovies Test'),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('Hello World'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that our app displays basic elements
|
||||
expect(find.text('NeoMovies Test'), findsOneWidget);
|
||||
expect(find.text('Hello World'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Download progress indicator test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
LinearProgressIndicator(value: 0.5),
|
||||
Text('50%'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify progress indicator and text
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('50%'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('List tile with popup menu test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ListTile(
|
||||
title: const Text('Test Torrent'),
|
||||
trailing: PopupMenuButton<String>(
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'pause',
|
||||
child: Text('Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify list tile
|
||||
expect(find.text('Test Torrent'), findsOneWidget);
|
||||
expect(find.byType(PopupMenuButton<String>), findsOneWidget);
|
||||
|
||||
// Tap the popup menu button
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify menu items appear
|
||||
expect(find.text('Delete'), findsOneWidget);
|
||||
expect(find.text('Pause'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <dynamic_color/dynamic_color_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
flutter_secure_storage_windows
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user